<?php
namespace App\Controller\Api;
use App\Service\OnlyOfficeDocBuilderService;
use App\Service\HtmlToDocBuilderConverter;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;
use Psr\Log\LoggerInterface;
/**
* @Route("/")
*/
class OnlyOfficeController extends AbstractController
{
private OnlyOfficeDocBuilderService $docBuilder;
private HtmlToDocBuilderConverter $htmlConverter;
private LoggerInterface $logger;
public function __construct(
OnlyOfficeDocBuilderService $docBuilder,
HtmlToDocBuilderConverter $htmlConverter,
LoggerInterface $logger
) {
$this->docBuilder = $docBuilder;
$this->htmlConverter = $htmlConverter;
$this->logger = $logger;
}
/**
* Convert document to given format
* @Route("/convert-to", name="api_onlyoffice_convert_to", methods={"POST"})
*/
public function convertTo(Request $request): Response
{
$data = json_decode($request->getContent(), true);
if (!isset($data['document_url'])) {
return $this->json(['error' => 'document_url is required'], Response::HTTP_BAD_REQUEST);
}
if (!isset($data['output_format'])) {
return $this->json(['error' => 'output_format is required'], Response::HTTP_BAD_REQUEST);
}
try {
$convertedFile = $this->processDocument($data['document_url'], $data['output_format'], [
'type' => 'convert',
]);
return new Response($convertedFile);
} catch (\Exception $e) {
return $this->json(['error' => $e->getMessage()], Response::HTTP_INTERNAL_SERVER_ERROR);
}
}
public function escapeDocBuilderString(string $value): string {
return str_replace(
["\\", "\r\n", "\r", "\n", "'"],
["\\\\", "\\n", "\\n", "\\n", "\\'"],
$value
);
}
/**
* Convert document to PDF
* @Route("/convert-to-pdf", name="api_onlyoffice_convert_to_pdf", methods={"POST"})
*/
public function convertToPdf(Request $request): Response
{
$data = json_decode($request->getContent(), true);
if (!isset($data['document_url'])) {
return $this->json(['error' => 'document_url is required'], Response::HTTP_BAD_REQUEST);
}
try {
$convertedFile = $this->processDocument($data['document_url'], 'pdf', [
'type' => 'convert',
]);
return new Response($convertedFile);
} catch (\Exception $e) {
return $this->json(['error' => $e->getMessage()], Response::HTTP_INTERNAL_SERVER_ERROR);
}
}
/**
* Replace labels with text in document
* @Route("/replace-labels-with-text", name="api_onlyoffice_replace_labels_with_text", methods={"POST"})
*/
public function replaceLabelsWithText(Request $request): Response
{
$data = json_decode($request->getContent(), true);
if (!isset($data['document_url']) || !isset($data['labels'])) {
return $this->json(
['error' => 'document_url and labels are required'],
Response::HTTP_BAD_REQUEST
);
}
try {
$resultFile = $this->processDocument($data['document_url'], 'docx', [
'type' => 'replaceText',
'labels' => $data['labels']
]);
return new Response($resultFile);
} catch (\Exception $e) {
return $this->json(['error' => $e->getMessage()], Response::HTTP_INTERNAL_SERVER_ERROR);
}
}
/**
* Replace labels with HTML content in document.
* Unlike replaceLabelsWithText, this method interprets HTML content and generates
* structured document elements (paragraphs, headings, tables, lists, formatted text, etc.)
*
* Supported HTML elements:
* - Block elements: <p>, <h1>, <h2>, <h3>, <div>
* - Text formatting: <strong>, <b>, <em>, <i>, <u>
* - Lists: <ul>, <ol>, <li>
* - Tables: <table>, <tr>, <td>, <th>, <thead>, <tbody>
* - Media: <img> (with src, width, height attributes)
* - Line breaks: <br>
*
* @Route("/replace-labels-html-with-text", name="api_onlyoffice_replace_labels_html_with_text", methods={"POST"})
*/
public function replaceLabelsHtmlWithText(Request $request): Response
{
$data = json_decode($request->getContent(), true);
if (!isset($data['document_url']) || !isset($data['labels'])) {
return $this->json(
['error' => 'document_url and labels are required'],
Response::HTTP_BAD_REQUEST
);
}
try {
$processedLabels = [];
foreach ($data['labels'] as $labelKey => $labelValue) {
$labelValue = $this->normalizeLabelValue($labelValue);
$processedLabels[$labelKey] = [
'content' => $labelValue,
'docbuilder' => $this->htmlConverter->htmlToDocBuilder($labelValue)
];
}
$resultFile = $this->processDocument($data['document_url'], 'docx', [
'type' => 'replaceHtml',
// 'labels' => $data['labels']
'labels' => $processedLabels,
]);
return new Response($resultFile);
} catch (\Exception $e) {
return $this->json(['error' => $e->getMessage()], Response::HTTP_INTERNAL_SERVER_ERROR);
}
}
/**
* Replace labels with text in document
* @Route("/replace-labels-with-image", name="api_onlyoffice_replace_labels_with_image", methods={"POST"})
*/
public function replaceLabelsWithImage(Request $request): Response
{
$data = json_decode($request->getContent(), true);
if (!isset($data['document_url']) || !isset($data['image'])) {
return $this->json(
['error' => 'document_url and image are required'],
Response::HTTP_BAD_REQUEST
);
}
try {
$resultFile = $this->processDocument($data['document_url'], 'docx', [
'type' => 'replaceImage',
'label' => $data['label'],
'image' => $data['image'],
'width' => intval($data['width'] ?? 220),
'height' => intval($data['height'] ?? 250)
]);
return new Response($resultFile);
} catch (\Exception $e) {
return $this->json(['error' => $e->getMessage()], Response::HTTP_INTERNAL_SERVER_ERROR);
}
}
/**
* Add watermark to document
* @Route("/add-watermark", name="api_onlyoffice_add_watermark", methods={"POST"})
*/
public function addWatermark(Request $request): Response
{
$data = json_decode($request->getContent(), true);
if (!isset($data['document_url']) || !isset($data['watermark'])) {
return $this->json(
['error' => 'document_url and watermark are required'],
Response::HTTP_BAD_REQUEST
);
}
try {
$resultFile = $this->processDocument($data['document_url'], 'docx', [
'type' => 'watermark',
'watermark' => $data['watermark'],
'watermark_type' => $data['watermark_type'] ?? 'text',
]);
return new Response($resultFile);
} catch (\Exception $e) {
return $this->json(['error' => $e->getMessage()], Response::HTTP_INTERNAL_SERVER_ERROR);
}
}
private function processDocument(string $documentUrl, $outputFormat, array $options): string
{
$outName = 'result_' . bin2hex(random_bytes(6)) . '.' . $outputFormat;
// Script para OnlyOffice DocBuilder
// $script = "builder.SetTmpFolder('DocBuilderTemp');\nbuilder.OpenFile('$documentUrl', '');\nvar doc = Api.GetDocument();\n";
// Force language to avoid locale issues in generated documents (e.g. "1st", "2nd", etc. in English instead of "1º", "2º" in Spanish)
$script = "builder.SetTmpFolder('DocBuilderTemp');\n
builder.OpenFile('$documentUrl', '');\n
var doc = Api.GetDocument();\n
var LANG = 'es-ES';\n
try { doc.GetDefaultTextPr().SetLanguage(LANG); } catch(e) {}\n
try { doc.GetCore().SetLanguage(LANG); } catch(e) {}\n
function applyLangToParagraphs(paragraphs, lang) {\n
if (!paragraphs) return;\n
for (var i = 0; i < paragraphs.length; i++) {\n
var p = paragraphs[i];\n
try {\n
var pr = p.GetParaPr();\n
if (pr && pr.GetTextPr) { pr.GetTextPr().SetLanguage(lang); }\n
} catch(e) {}\n
var count = 0;\n
try { count = p.GetElementsCount(); } catch(e) {}\n
for (var j = 0; j < count; j++) {\n
try {\n
var el = p.GetElement(j);\n
if (el && el.SetLanguage) { el.SetLanguage(lang); }\n
} catch(e) {}\n
}\n
}\n
}\n
applyLangToParagraphs(doc.GetAllParagraphs(), LANG);\n
try {\n
var sections = [doc.GetFinalSection()];\n
for (var s = 0; s < sections.length; s++) {\n
var sec = sections[s];\n
var hdrTypes = ['default','even','first'];\n
for (var h = 0; h < hdrTypes.length; h++) {\n
try {\n
var hdr = sec.GetHeader(hdrTypes[h], false);\n
if (hdr) applyLangToParagraphs(hdr.GetAllParagraphs(), LANG);\n
} catch(e) {}\n
try {\n
var ftr = sec.GetFooter(hdrTypes[h], false);\n
if (ftr) applyLangToParagraphs(ftr.GetAllParagraphs(), LANG);\n
} catch(e) {}\n
}\n
}\n
} catch(e) {}\n";
// Force Lang in ordinals list to avoid locale issues in generated documents (e.g. "1st", "2nd", etc. in English instead of "1º", "2º" in Spanish)
// $script = "builder.SetTmpFolder('DocBuilderTemp');\n";
// $script .= "builder.OpenFile('$documentUrl', '');\n";
// $script .= "var doc = Api.GetDocument();\n";
// $script .= "doc.SetLanguage('es-ES');\n";
// $script .= "try {\n";
// $script .= " var paragraphs = doc.GetAllParagraphs();\n";
// $script .= " for (var i = 0; i < paragraphs.length; i++) {\n";
// $script .= " var p = paragraphs[i];\n";
// $script .= " var t = p.GetText();\n";
// $script .= " }\n";
// $script .= "} catch(e) {\n";
// $script .= " builder.WriteLog(e.message);\n";
// $script .= "}\n";
// Test set double force by builder
// $script .= "var oElements = doc.GetAllElements('paragraph');\n";
// $script .= "for (var i = 0; i < oElements.length; i++) {\n";
// $script .= " var oPar = oElements[i];\n";
// $script .= " var oRuns = oPar.GetAllElements('run');\n";
// $script .= " for (var j = 0; j < oRuns.length; j++) {\n";
// $script .= " oRuns[j].SetLanguage('es-ES');\n";
// $script .= " }\n";
// $script .= "}\n";
switch ($options['type']) {
case 'replaceText':
$labels = $options['labels'];
foreach ($labels as $key => $value) {
$value = $this->normalizeLabelValue($value);
if (str_contains($value, "\n") || str_contains($value, "\r")) {
$lines = preg_split('/\r\n|\r|\n/', $value);
$script .= "var found = doc.Search('" . $this->escapeDocBuilderString($key) . "');\n";
$script .= "if (found && found.length > 0) {\n";
$script .= " for (var fi = found.length - 1; fi >= 0; fi--) {\n";
$script .= " var _content = [];\n";
foreach ($lines as $line) {
$script .= " var _p = Api.CreateParagraph();\n";
$script .= " _p.AddText('" . $this->escapeDocBuilderString($line) . "');\n";
$script .= " _content.push(_p);\n";
}
$script .= " found[fi].Select();\n";
$script .= " doc.InsertContent(_content);\n";
$script .= " }\n";
$script .= "}\n";
} else {
$script .= "doc.SearchAndReplace({searchString: '" . $this->escapeDocBuilderString($key) . "', replaceString: '" . $this->escapeDocBuilderString($value) . "', matchCase: false});\n";
}
}
break;
case 'replaceImage':
$label = $options['label'];
$image = $options['image'];
$width = $options['width'];
$height = $options['height'];
$wEMU = intval(round($width * 3600));
$hEMU = intval(round($height * 3600));
$script .= "var found = doc.Search('" . addslashes($label) . "');\nif (found && found.length>0){ found[0].Select();\nvar p = Api.CreateParagraph();\nvar img = Api.CreateImage('" . addslashes($image) . "', $wEMU, $hEMU);\nimg.SetWrappingStyle('inline');\np.AddDrawing(img);\ndoc.InsertContent([p]);\n}\n";
break;
case 'watermark':
$watermarkType = $options['watermark_type'] ?? 'text';
if ($watermarkType === 'text') {
$texto = $options['watermark'];
$script .= "doc.InsertWatermark('$texto', true);\n";
} else {
$image = $options['watermark'];
$script .= "var sec=doc.GetFinalSection();\nvar wTw=sec.GetPageWidth();\nvar hTw=sec.GetPageHeight();\nvar EMU_PER_TWIP=635;\nvar wEMU=wTw*EMU_PER_TWIP;\nvar hEMU=hTw*EMU_PER_TWIP;\ndoc.InsertWatermark('WATERMARK',false);\nvar ws=doc.GetWatermarkSettings();\nws.SetType('image');\nws.SetImageURL('" . addslashes($image) . "');\nws.SetImageSize(wEMU,hEMU);\nws.SetDirection('horizontal');\nws.SetOpacity(100);\ndoc.SetWatermarkSettings(ws);\n";
}
break;
case 'replaceHtml':
$labels = $options['labels'];
foreach ($labels as $key => $value) {
$content = $this->normalizeLabelValue($value['content']);
$hasNewlines = str_contains($content, "\n") || str_contains($content, "\r");
if ($this->shouldUseInlineReplacement($content)) {
$inlineText = $this->htmlToInlineText($content);
if (str_contains($inlineText, "\n") || str_contains($inlineText, "\r")) {
$script .= "var found = doc.Search('" . $this->escapeDocBuilderString($key) . "');\n";
$script .= "if (found && found.length > 0) {\n";
$script .= " for (var fi = found.length - 1; fi >= 0; fi--) {\n";
$script .= " try {\n";
$script .= " found[fi].Delete();\n";
$script .= " found[fi].AddText('" . $this->escapeDocBuilderString($inlineText) . "');\n";
$script .= " } catch (e) {\n";
$script .= " found[fi].Select();\n";
$script .= " doc.SearchAndReplace({searchString: '" . $this->escapeDocBuilderString($key) . "', replaceString: '" . $this->escapeDocBuilderString($inlineText) . "', matchCase: false});\n";
$script .= " }\n";
$script .= " }\n";
$script .= "}\n";
} else {
$script .= "doc.SearchAndReplace({searchString: '" . $this->escapeDocBuilderString($key) . "', replaceString: '" . $this->escapeDocBuilderString($inlineText) . "', matchCase: false});\n";
}
continue;
}
if (strip_tags($content) === $content && !$hasNewlines) {
// $script .= "doc.SearchAndReplace({searchString: '" . addslashes($key) . "', replaceString: '" . addslashes($content) . "', matchCase: false});\n";
$script .= "doc.SearchAndReplace({searchString: '" . $this->escapeDocBuilderString($key) . "', replaceString: '" . $this->escapeDocBuilderString($content) . "', matchCase: false});\n";
} else {
$script .= "var found = doc.Search('" . $this->escapeDocBuilderString($key) . "');\n";
$script .= "if (found && found.length > 0) {\n";
$script .= " for (var fi = found.length - 1; fi >= 0; fi--) {\n";
$script .= " var range = found[fi];\n";
$script .= " var _content = [];\n";
$script .= " var _inlinePara = Api.CreateParagraph();\n";
$script .= " var _inlineUsed = false;\n";
$script .= " var __para = null;\n";
$script .= " var __cell = null;\n";
$script .= " try { __para = range.GetParagraph(0); } catch (e) {}\n";
$script .= " if (!__para) { try { __para = range.GetParagraph(); } catch (e) {} }\n";
$script .= " if (__para) { try { __cell = __para.GetParentTableCell(); } catch (e) {} }\n";
$script .= " var oPara = null;\n";
$script .= " if (__cell && __para) {\n";
$script .= " oPara = __para;\n";
$script .= " try { range.Delete(); } catch (e) {}\n";
$script .= " } else {\n";
$script .= " oPara = {\n";
$script .= " AddElement: function(el) { _inlineUsed = true; _inlinePara.AddElement(el); },\n";
$script .= " GetParentTableCell: function() {\n";
$script .= " return {\n";
$script .= " GetContent: function() {\n";
$script .= " return { Push: function(el) { _content.push(el); } };\n";
$script .= " }\n";
$script .= " };\n";
$script .= " }\n";
$script .= " };\n";
$script .= " }\n";
$script .= " " . $value['docbuilder'] . "\n";
$script .= " if (!__cell) {\n";
$script .= " if (_inlineUsed) { _content.unshift(_inlinePara); }\n";
$script .= " range.Select();\n";
$script .= " if (_content.length > 0) { doc.InsertContent(_content); }\n";
$script .= " }\n";
$script .= " }\n";
$script .= "}\n";
}
}
break;
default:
break;
}
// Cerrar y guardar el documento
$script .= "builder.SaveFile('$outputFormat', '$outName');\nbuilder.CloseFile();";
$this->logger->debug('Generated DocBuilder script', [
'type' => $options['type'],
'script' => $script
]);
try {
// Build the document and upload to Google Cloud Storage
$urlScript = $this->docBuilder->construirSubirDocumento($script);
$doc = $this->docBuilder->enviarDocBuilder($urlScript, $outName, $outputFormat);
return $doc['urls'][$outName];
} catch (\Exception $e) {
$this->logger->error('Error al convertir documento', [
'error' => $e->getMessage(),
'documentUrl' => $documentUrl,
'outputFormat' => $outputFormat,
'trace' => $e->getTraceAsString()
]);
throw new \RuntimeException('Failed to convert document: ' . $e->getMessage());
}
}
private function shouldUseInlineReplacement(string $content): bool
{
$lowerContent = mb_strtolower($content);
// Block tags usually create new paragraphs/structures in DOCX and can break inline sentences.
$hasBlockTags = (bool) preg_match('/<\s*(p|div|section|article|table|thead|tbody|tr|td|th|ul|ol|li|h1|h2|h3|h4|h5|h6)\b/', $lowerContent);
if ($hasBlockTags) {
return false;
}
// Inline-like content (including <br>) is safer via SearchAndReplace to preserve paragraph flow.
return strip_tags($content) !== $content || str_contains($content, "\n") || str_contains($content, "\r");
}
private function htmlToInlineText(string $content): string
{
$textWithBreaks = preg_replace('/<\s*br\s*\/?>/i', "\n", $content) ?? $content;
$text = strip_tags($textWithBreaks);
$text = html_entity_decode($text, ENT_QUOTES | ENT_HTML5, 'UTF-8');
// Normalize whitespace but preserve line breaks coming from <br>.
$text = str_replace(["\r\n", "\r"], "\n", $text);
$text = preg_replace('/[^\S\n]+/u', ' ', $text) ?? $text;
// Trim only horizontal whitespace around line breaks, without collapsing consecutive \n.
$text = preg_replace('/\n[^\S\n]+/u', "\n", $text) ?? $text;
$text = preg_replace('/[^\S\n]+\n/u', "\n", $text) ?? $text;
return trim($text);
}
private function normalizeLabelValue(string $value): string
{
$decoded = html_entity_decode($value, ENT_QUOTES | ENT_HTML5, 'UTF-8');
// Decodificar iterativamente hasta que no haya más entidades
while ($decoded !== $value) {
$value = $decoded;
$decoded = html_entity_decode($value, ENT_QUOTES | ENT_HTML5, 'UTF-8');
}
return $decoded;
}
}