src/Controller/Api/OnlyOfficeController.php line 71

Open in your IDE?
  1. <?php
  2. namespace App\Controller\Api;
  3. use App\Service\OnlyOfficeDocBuilderService;
  4. use App\Service\HtmlToDocBuilderConverter;
  5. use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
  6. use Symfony\Component\HttpFoundation\Request;
  7. use Symfony\Component\HttpFoundation\Response;
  8. use Symfony\Component\Routing\Annotation\Route;
  9. use Psr\Log\LoggerInterface;
  10. /**
  11. * @Route("/")
  12. */
  13. class OnlyOfficeController extends AbstractController
  14. {
  15. private OnlyOfficeDocBuilderService $docBuilder;
  16. private HtmlToDocBuilderConverter $htmlConverter;
  17. private LoggerInterface $logger;
  18. public function __construct(
  19. OnlyOfficeDocBuilderService $docBuilder,
  20. HtmlToDocBuilderConverter $htmlConverter,
  21. LoggerInterface $logger
  22. ) {
  23. $this->docBuilder = $docBuilder;
  24. $this->htmlConverter = $htmlConverter;
  25. $this->logger = $logger;
  26. }
  27. /**
  28. * Convert document to given format
  29. * @Route("/convert-to", name="api_onlyoffice_convert_to", methods={"POST"})
  30. */
  31. public function convertTo(Request $request): Response
  32. {
  33. $data = json_decode($request->getContent(), true);
  34. if (!isset($data['document_url'])) {
  35. return $this->json(['error' => 'document_url is required'], Response::HTTP_BAD_REQUEST);
  36. }
  37. if (!isset($data['output_format'])) {
  38. return $this->json(['error' => 'output_format is required'], Response::HTTP_BAD_REQUEST);
  39. }
  40. try {
  41. $convertedFile = $this->processDocument($data['document_url'], $data['output_format'], [
  42. 'type' => 'convert',
  43. ]);
  44. return new Response($convertedFile);
  45. } catch (\Exception $e) {
  46. return $this->json(['error' => $e->getMessage()], Response::HTTP_INTERNAL_SERVER_ERROR);
  47. }
  48. }
  49. public function escapeDocBuilderString(string $value): string {
  50. return str_replace(
  51. ["\\", "\r\n", "\r", "\n", "'"],
  52. ["\\\\", "\\n", "\\n", "\\n", "\\'"],
  53. $value
  54. );
  55. }
  56. /**
  57. * Convert document to PDF
  58. * @Route("/convert-to-pdf", name="api_onlyoffice_convert_to_pdf", methods={"POST"})
  59. */
  60. public function convertToPdf(Request $request): Response
  61. {
  62. $data = json_decode($request->getContent(), true);
  63. if (!isset($data['document_url'])) {
  64. return $this->json(['error' => 'document_url is required'], Response::HTTP_BAD_REQUEST);
  65. }
  66. try {
  67. $convertedFile = $this->processDocument($data['document_url'], 'pdf', [
  68. 'type' => 'convert',
  69. ]);
  70. return new Response($convertedFile);
  71. } catch (\Exception $e) {
  72. return $this->json(['error' => $e->getMessage()], Response::HTTP_INTERNAL_SERVER_ERROR);
  73. }
  74. }
  75. /**
  76. * Replace labels with text in document
  77. * @Route("/replace-labels-with-text", name="api_onlyoffice_replace_labels_with_text", methods={"POST"})
  78. */
  79. public function replaceLabelsWithText(Request $request): Response
  80. {
  81. $data = json_decode($request->getContent(), true);
  82. if (!isset($data['document_url']) || !isset($data['labels'])) {
  83. return $this->json(
  84. ['error' => 'document_url and labels are required'],
  85. Response::HTTP_BAD_REQUEST
  86. );
  87. }
  88. try {
  89. $resultFile = $this->processDocument($data['document_url'], 'docx', [
  90. 'type' => 'replaceText',
  91. 'labels' => $data['labels']
  92. ]);
  93. return new Response($resultFile);
  94. } catch (\Exception $e) {
  95. return $this->json(['error' => $e->getMessage()], Response::HTTP_INTERNAL_SERVER_ERROR);
  96. }
  97. }
  98. /**
  99. * Replace labels with HTML content in document.
  100. * Unlike replaceLabelsWithText, this method interprets HTML content and generates
  101. * structured document elements (paragraphs, headings, tables, lists, formatted text, etc.)
  102. *
  103. * Supported HTML elements:
  104. * - Block elements: <p>, <h1>, <h2>, <h3>, <div>
  105. * - Text formatting: <strong>, <b>, <em>, <i>, <u>
  106. * - Lists: <ul>, <ol>, <li>
  107. * - Tables: <table>, <tr>, <td>, <th>, <thead>, <tbody>
  108. * - Media: <img> (with src, width, height attributes)
  109. * - Line breaks: <br>
  110. *
  111. * @Route("/replace-labels-html-with-text", name="api_onlyoffice_replace_labels_html_with_text", methods={"POST"})
  112. */
  113. public function replaceLabelsHtmlWithText(Request $request): Response
  114. {
  115. $data = json_decode($request->getContent(), true);
  116. if (!isset($data['document_url']) || !isset($data['labels'])) {
  117. return $this->json(
  118. ['error' => 'document_url and labels are required'],
  119. Response::HTTP_BAD_REQUEST
  120. );
  121. }
  122. try {
  123. $processedLabels = [];
  124. foreach ($data['labels'] as $labelKey => $labelValue) {
  125. $labelValue = $this->normalizeLabelValue($labelValue);
  126. $processedLabels[$labelKey] = [
  127. 'content' => $labelValue,
  128. 'docbuilder' => $this->htmlConverter->htmlToDocBuilder($labelValue)
  129. ];
  130. }
  131. $resultFile = $this->processDocument($data['document_url'], 'docx', [
  132. 'type' => 'replaceHtml',
  133. // 'labels' => $data['labels']
  134. 'labels' => $processedLabels,
  135. ]);
  136. return new Response($resultFile);
  137. } catch (\Exception $e) {
  138. return $this->json(['error' => $e->getMessage()], Response::HTTP_INTERNAL_SERVER_ERROR);
  139. }
  140. }
  141. /**
  142. * Replace labels with text in document
  143. * @Route("/replace-labels-with-image", name="api_onlyoffice_replace_labels_with_image", methods={"POST"})
  144. */
  145. public function replaceLabelsWithImage(Request $request): Response
  146. {
  147. $data = json_decode($request->getContent(), true);
  148. if (!isset($data['document_url']) || !isset($data['image'])) {
  149. return $this->json(
  150. ['error' => 'document_url and image are required'],
  151. Response::HTTP_BAD_REQUEST
  152. );
  153. }
  154. try {
  155. $resultFile = $this->processDocument($data['document_url'], 'docx', [
  156. 'type' => 'replaceImage',
  157. 'label' => $data['label'],
  158. 'image' => $data['image'],
  159. 'width' => intval($data['width'] ?? 220),
  160. 'height' => intval($data['height'] ?? 250)
  161. ]);
  162. return new Response($resultFile);
  163. } catch (\Exception $e) {
  164. return $this->json(['error' => $e->getMessage()], Response::HTTP_INTERNAL_SERVER_ERROR);
  165. }
  166. }
  167. /**
  168. * Add watermark to document
  169. * @Route("/add-watermark", name="api_onlyoffice_add_watermark", methods={"POST"})
  170. */
  171. public function addWatermark(Request $request): Response
  172. {
  173. $data = json_decode($request->getContent(), true);
  174. if (!isset($data['document_url']) || !isset($data['watermark'])) {
  175. return $this->json(
  176. ['error' => 'document_url and watermark are required'],
  177. Response::HTTP_BAD_REQUEST
  178. );
  179. }
  180. try {
  181. $resultFile = $this->processDocument($data['document_url'], 'docx', [
  182. 'type' => 'watermark',
  183. 'watermark' => $data['watermark'],
  184. 'watermark_type' => $data['watermark_type'] ?? 'text',
  185. ]);
  186. return new Response($resultFile);
  187. } catch (\Exception $e) {
  188. return $this->json(['error' => $e->getMessage()], Response::HTTP_INTERNAL_SERVER_ERROR);
  189. }
  190. }
  191. private function processDocument(string $documentUrl, $outputFormat, array $options): string
  192. {
  193. $outName = 'result_' . bin2hex(random_bytes(6)) . '.' . $outputFormat;
  194. // Script para OnlyOffice DocBuilder
  195. // $script = "builder.SetTmpFolder('DocBuilderTemp');\nbuilder.OpenFile('$documentUrl', '');\nvar doc = Api.GetDocument();\n";
  196. // Force language to avoid locale issues in generated documents (e.g. "1st", "2nd", etc. in English instead of "1º", "2º" in Spanish)
  197. $script = "builder.SetTmpFolder('DocBuilderTemp');\n
  198. builder.OpenFile('$documentUrl', '');\n
  199. var doc = Api.GetDocument();\n
  200. var LANG = 'es-ES';\n
  201. try { doc.GetDefaultTextPr().SetLanguage(LANG); } catch(e) {}\n
  202. try { doc.GetCore().SetLanguage(LANG); } catch(e) {}\n
  203. function applyLangToParagraphs(paragraphs, lang) {\n
  204. if (!paragraphs) return;\n
  205. for (var i = 0; i < paragraphs.length; i++) {\n
  206. var p = paragraphs[i];\n
  207. try {\n
  208. var pr = p.GetParaPr();\n
  209. if (pr && pr.GetTextPr) { pr.GetTextPr().SetLanguage(lang); }\n
  210. } catch(e) {}\n
  211. var count = 0;\n
  212. try { count = p.GetElementsCount(); } catch(e) {}\n
  213. for (var j = 0; j < count; j++) {\n
  214. try {\n
  215. var el = p.GetElement(j);\n
  216. if (el && el.SetLanguage) { el.SetLanguage(lang); }\n
  217. } catch(e) {}\n
  218. }\n
  219. }\n
  220. }\n
  221. applyLangToParagraphs(doc.GetAllParagraphs(), LANG);\n
  222. try {\n
  223. var sections = [doc.GetFinalSection()];\n
  224. for (var s = 0; s < sections.length; s++) {\n
  225. var sec = sections[s];\n
  226. var hdrTypes = ['default','even','first'];\n
  227. for (var h = 0; h < hdrTypes.length; h++) {\n
  228. try {\n
  229. var hdr = sec.GetHeader(hdrTypes[h], false);\n
  230. if (hdr) applyLangToParagraphs(hdr.GetAllParagraphs(), LANG);\n
  231. } catch(e) {}\n
  232. try {\n
  233. var ftr = sec.GetFooter(hdrTypes[h], false);\n
  234. if (ftr) applyLangToParagraphs(ftr.GetAllParagraphs(), LANG);\n
  235. } catch(e) {}\n
  236. }\n
  237. }\n
  238. } catch(e) {}\n";
  239. // 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)
  240. // $script = "builder.SetTmpFolder('DocBuilderTemp');\n";
  241. // $script .= "builder.OpenFile('$documentUrl', '');\n";
  242. // $script .= "var doc = Api.GetDocument();\n";
  243. // $script .= "doc.SetLanguage('es-ES');\n";
  244. // $script .= "try {\n";
  245. // $script .= " var paragraphs = doc.GetAllParagraphs();\n";
  246. // $script .= " for (var i = 0; i < paragraphs.length; i++) {\n";
  247. // $script .= " var p = paragraphs[i];\n";
  248. // $script .= " var t = p.GetText();\n";
  249. // $script .= " }\n";
  250. // $script .= "} catch(e) {\n";
  251. // $script .= " builder.WriteLog(e.message);\n";
  252. // $script .= "}\n";
  253. // Test set double force by builder
  254. // $script .= "var oElements = doc.GetAllElements('paragraph');\n";
  255. // $script .= "for (var i = 0; i < oElements.length; i++) {\n";
  256. // $script .= " var oPar = oElements[i];\n";
  257. // $script .= " var oRuns = oPar.GetAllElements('run');\n";
  258. // $script .= " for (var j = 0; j < oRuns.length; j++) {\n";
  259. // $script .= " oRuns[j].SetLanguage('es-ES');\n";
  260. // $script .= " }\n";
  261. // $script .= "}\n";
  262. switch ($options['type']) {
  263. case 'replaceText':
  264. $labels = $options['labels'];
  265. foreach ($labels as $key => $value) {
  266. $value = $this->normalizeLabelValue($value);
  267. if (str_contains($value, "\n") || str_contains($value, "\r")) {
  268. $lines = preg_split('/\r\n|\r|\n/', $value);
  269. $script .= "var found = doc.Search('" . $this->escapeDocBuilderString($key) . "');\n";
  270. $script .= "if (found && found.length > 0) {\n";
  271. $script .= " for (var fi = found.length - 1; fi >= 0; fi--) {\n";
  272. $script .= " var _content = [];\n";
  273. foreach ($lines as $line) {
  274. $script .= " var _p = Api.CreateParagraph();\n";
  275. $script .= " _p.AddText('" . $this->escapeDocBuilderString($line) . "');\n";
  276. $script .= " _content.push(_p);\n";
  277. }
  278. $script .= " found[fi].Select();\n";
  279. $script .= " doc.InsertContent(_content);\n";
  280. $script .= " }\n";
  281. $script .= "}\n";
  282. } else {
  283. $script .= "doc.SearchAndReplace({searchString: '" . $this->escapeDocBuilderString($key) . "', replaceString: '" . $this->escapeDocBuilderString($value) . "', matchCase: false});\n";
  284. }
  285. }
  286. break;
  287. case 'replaceImage':
  288. $label = $options['label'];
  289. $image = $options['image'];
  290. $width = $options['width'];
  291. $height = $options['height'];
  292. $wEMU = intval(round($width * 3600));
  293. $hEMU = intval(round($height * 3600));
  294. $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";
  295. break;
  296. case 'watermark':
  297. $watermarkType = $options['watermark_type'] ?? 'text';
  298. if ($watermarkType === 'text') {
  299. $texto = $options['watermark'];
  300. $script .= "doc.InsertWatermark('$texto', true);\n";
  301. } else {
  302. $image = $options['watermark'];
  303. $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";
  304. }
  305. break;
  306. case 'replaceHtml':
  307. $labels = $options['labels'];
  308. foreach ($labels as $key => $value) {
  309. $content = $this->normalizeLabelValue($value['content']);
  310. $hasNewlines = str_contains($content, "\n") || str_contains($content, "\r");
  311. if ($this->shouldUseInlineReplacement($content)) {
  312. $inlineText = $this->htmlToInlineText($content);
  313. if (str_contains($inlineText, "\n") || str_contains($inlineText, "\r")) {
  314. $script .= "var found = doc.Search('" . $this->escapeDocBuilderString($key) . "');\n";
  315. $script .= "if (found && found.length > 0) {\n";
  316. $script .= " for (var fi = found.length - 1; fi >= 0; fi--) {\n";
  317. $script .= " try {\n";
  318. $script .= " found[fi].Delete();\n";
  319. $script .= " found[fi].AddText('" . $this->escapeDocBuilderString($inlineText) . "');\n";
  320. $script .= " } catch (e) {\n";
  321. $script .= " found[fi].Select();\n";
  322. $script .= " doc.SearchAndReplace({searchString: '" . $this->escapeDocBuilderString($key) . "', replaceString: '" . $this->escapeDocBuilderString($inlineText) . "', matchCase: false});\n";
  323. $script .= " }\n";
  324. $script .= " }\n";
  325. $script .= "}\n";
  326. } else {
  327. $script .= "doc.SearchAndReplace({searchString: '" . $this->escapeDocBuilderString($key) . "', replaceString: '" . $this->escapeDocBuilderString($inlineText) . "', matchCase: false});\n";
  328. }
  329. continue;
  330. }
  331. if (strip_tags($content) === $content && !$hasNewlines) {
  332. // $script .= "doc.SearchAndReplace({searchString: '" . addslashes($key) . "', replaceString: '" . addslashes($content) . "', matchCase: false});\n";
  333. $script .= "doc.SearchAndReplace({searchString: '" . $this->escapeDocBuilderString($key) . "', replaceString: '" . $this->escapeDocBuilderString($content) . "', matchCase: false});\n";
  334. } else {
  335. $script .= "var found = doc.Search('" . $this->escapeDocBuilderString($key) . "');\n";
  336. $script .= "if (found && found.length > 0) {\n";
  337. $script .= " for (var fi = found.length - 1; fi >= 0; fi--) {\n";
  338. $script .= " var range = found[fi];\n";
  339. $script .= " var _content = [];\n";
  340. $script .= " var _inlinePara = Api.CreateParagraph();\n";
  341. $script .= " var _inlineUsed = false;\n";
  342. $script .= " var __para = null;\n";
  343. $script .= " var __cell = null;\n";
  344. $script .= " try { __para = range.GetParagraph(0); } catch (e) {}\n";
  345. $script .= " if (!__para) { try { __para = range.GetParagraph(); } catch (e) {} }\n";
  346. $script .= " if (__para) { try { __cell = __para.GetParentTableCell(); } catch (e) {} }\n";
  347. $script .= " var oPara = null;\n";
  348. $script .= " if (__cell && __para) {\n";
  349. $script .= " oPara = __para;\n";
  350. $script .= " try { range.Delete(); } catch (e) {}\n";
  351. $script .= " } else {\n";
  352. $script .= " oPara = {\n";
  353. $script .= " AddElement: function(el) { _inlineUsed = true; _inlinePara.AddElement(el); },\n";
  354. $script .= " GetParentTableCell: function() {\n";
  355. $script .= " return {\n";
  356. $script .= " GetContent: function() {\n";
  357. $script .= " return { Push: function(el) { _content.push(el); } };\n";
  358. $script .= " }\n";
  359. $script .= " };\n";
  360. $script .= " }\n";
  361. $script .= " };\n";
  362. $script .= " }\n";
  363. $script .= " " . $value['docbuilder'] . "\n";
  364. $script .= " if (!__cell) {\n";
  365. $script .= " if (_inlineUsed) { _content.unshift(_inlinePara); }\n";
  366. $script .= " range.Select();\n";
  367. $script .= " if (_content.length > 0) { doc.InsertContent(_content); }\n";
  368. $script .= " }\n";
  369. $script .= " }\n";
  370. $script .= "}\n";
  371. }
  372. }
  373. break;
  374. default:
  375. break;
  376. }
  377. // Cerrar y guardar el documento
  378. $script .= "builder.SaveFile('$outputFormat', '$outName');\nbuilder.CloseFile();";
  379. $this->logger->debug('Generated DocBuilder script', [
  380. 'type' => $options['type'],
  381. 'script' => $script
  382. ]);
  383. try {
  384. // Build the document and upload to Google Cloud Storage
  385. $urlScript = $this->docBuilder->construirSubirDocumento($script);
  386. $doc = $this->docBuilder->enviarDocBuilder($urlScript, $outName, $outputFormat);
  387. return $doc['urls'][$outName];
  388. } catch (\Exception $e) {
  389. $this->logger->error('Error al convertir documento', [
  390. 'error' => $e->getMessage(),
  391. 'documentUrl' => $documentUrl,
  392. 'outputFormat' => $outputFormat,
  393. 'trace' => $e->getTraceAsString()
  394. ]);
  395. throw new \RuntimeException('Failed to convert document: ' . $e->getMessage());
  396. }
  397. }
  398. private function shouldUseInlineReplacement(string $content): bool
  399. {
  400. $lowerContent = mb_strtolower($content);
  401. // Block tags usually create new paragraphs/structures in DOCX and can break inline sentences.
  402. $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);
  403. if ($hasBlockTags) {
  404. return false;
  405. }
  406. // Inline-like content (including <br>) is safer via SearchAndReplace to preserve paragraph flow.
  407. return strip_tags($content) !== $content || str_contains($content, "\n") || str_contains($content, "\r");
  408. }
  409. private function htmlToInlineText(string $content): string
  410. {
  411. $textWithBreaks = preg_replace('/<\s*br\s*\/?>/i', "\n", $content) ?? $content;
  412. $text = strip_tags($textWithBreaks);
  413. $text = html_entity_decode($text, ENT_QUOTES | ENT_HTML5, 'UTF-8');
  414. // Normalize whitespace but preserve line breaks coming from <br>.
  415. $text = str_replace(["\r\n", "\r"], "\n", $text);
  416. $text = preg_replace('/[^\S\n]+/u', ' ', $text) ?? $text;
  417. // Trim only horizontal whitespace around line breaks, without collapsing consecutive \n.
  418. $text = preg_replace('/\n[^\S\n]+/u', "\n", $text) ?? $text;
  419. $text = preg_replace('/[^\S\n]+\n/u', "\n", $text) ?? $text;
  420. return trim($text);
  421. }
  422. private function normalizeLabelValue(string $value): string
  423. {
  424. $decoded = html_entity_decode($value, ENT_QUOTES | ENT_HTML5, 'UTF-8');
  425. // Decodificar iterativamente hasta que no haya más entidades
  426. while ($decoded !== $value) {
  427. $value = $decoded;
  428. $decoded = html_entity_decode($value, ENT_QUOTES | ENT_HTML5, 'UTF-8');
  429. }
  430. return $decoded;
  431. }
  432. }