1 package com.acumenvelocity.ath.pagematcher;
2
3 import java.io.File;
4 import java.io.InputStream;
5 import java.io.OutputStream;
6 import java.nio.file.Files;
7 import java.nio.file.Path;
8 import java.nio.file.StandardCopyOption;
9 import java.util.ArrayList;
10 import java.util.HashMap;
11 import java.util.List;
12 import java.util.Map;
13 import java.util.concurrent.Callable;
14 import java.util.concurrent.CopyOnWriteArrayList;
15 import java.util.concurrent.ExecutorService;
16 import java.util.concurrent.Executors;
17 import java.util.concurrent.Future;
18 import java.util.concurrent.atomic.AtomicInteger;
19 import java.util.regex.Matcher;
20 import java.util.regex.Pattern;
21
22 import org.apache.pdfbox.Loader;
23 import org.apache.pdfbox.pdmodel.PDDocument;
24 import org.jodconverter.core.document.DefaultDocumentFormatRegistry;
25 import org.jodconverter.core.document.DocumentFormat;
26 import org.jodconverter.local.LocalConverter;
27 import org.jodconverter.local.office.LocalOfficeManager;
28
29 import com.acumenvelocity.ath.common.AthUtil;
30 import com.acumenvelocity.ath.common.Log;
31 import com.acumenvelocity.ath.common.exception.AthRuntimeException;
32 import com.acumenvelocity.ath.filters.pdf.AthPdfFilter;
33
34 import net.sf.okapi.common.StreamUtil;
35 import net.sf.okapi.common.filters.IFilter;
36 import net.sf.okapi.filters.openxml.OpenXMLFilter;
37
38 public class DocxPageMatcher {
39
40 private static final DocumentFormat PDF = DefaultDocumentFormatRegistry.PDF;
41 private static final DocumentFormat DOCX = DefaultDocumentFormatRegistry.DOCX;
42
43 private static final Pattern FONT_SIZE_PATTERN = Pattern.compile(
44 "(<w:(?:sz|szCs)\\b[^>]*\\sw:val=\")(\\d+)(\")");
45
46 private static final int NUM_PARALLEL_THREADS = 3;
47 private static final int DELTAS_PER_SET = 3;
48 private static final int MAX_SETS = 4;
49
50 private static LocalOfficeManager officeManager;
51
52 private static class AdjustmentResult {
53 final File docxFile;
54 final int pageCount;
55 final double delta;
56 final File pdfFile;
57
58 AdjustmentResult(File docxFile, int pageCount, double delta, File pdfFile) {
59 this.docxFile = docxFile;
60 this.pageCount = pageCount;
61 this.delta = delta;
62 this.pdfFile = pdfFile;
63 }
64 }
65
66 public static void init() throws Exception {
67 Log.info(DocxPageMatcher.class, "Initializing LibreOffice manager...");
68
69 System.setProperty("user.installation", "file:///app/lo-profile");
70
71 officeManager = LocalOfficeManager.builder()
72 .officeHome("/usr/lib/libreoffice")
73 .workingDir(new File("/app/lo-profile"))
74 .portNumbers(2002, 2003, 2004)
75 .maxTasksPerProcess(50)
76 .taskExecutionTimeout(60000L)
77 .processTimeout(120000L)
78 .build();
79
80 officeManager.start();
81 Log.info(DocxPageMatcher.class, "LibreOffice manager started successfully with 3 instances");
82 }
83
84 public static void done() throws Exception {
85 if (officeManager != null) {
86 officeManager.stop();
87 }
88 }
89
90
91
92
93
94
95
96
97 @SuppressWarnings("unused")
98 public static File matchPageCount(InputStream originalStream, File translationDocx)
99 throws Exception {
100
101 File originalPdf = null;
102 File translationPdf = null;
103
104 try {
105 originalPdf = AthUtil.createTempFile(".pdf");
106 StreamUtil.copy(originalStream, originalPdf);
107 int originalPageCount = getPdfPageCount(originalPdf);
108
109
110
111 int translationPageCount = getDocxPageCount(translationDocx, true);
112
113 translationPdf = AthUtil.createTempFile(".pdf");
114 convertDocxToPdf(translationDocx, translationPdf);
115
116 return translationPdf;
117
118 } catch (Exception e) {
119 AthUtil.deleteFile(translationPdf);
120 AthRuntimeException.logAndThrow(DocxPageMatcher.class, e,
121 "Cannot adjust page count in translation");
122
123 } finally {
124 AthUtil.deleteFile(originalPdf);
125 }
126
127 return null;
128 }
129
130
131
132
133
134
135
136
137
138 public static File matchPageCount(InputStream originalStream, File translation,
139 IFilter filter) throws Exception {
140
141 File originalPdf = null;
142 File translationPdf = null;
143
144 int originalPageCount = 0;
145 int translationPageCount = 0;
146
147 List<File> tempPdfs = new CopyOnWriteArrayList<>();
148 boolean isPdf = false;
149
150 try {
151 if (filter instanceof OpenXMLFilter) {
152 File tempDocx = AthUtil.createTempFile(".docx");
153 File tempPdf = AthUtil.createTempFile(".pdf");
154
155 try {
156 Log.info(DocxPageMatcher.class, "Getting original page count");
157 StreamUtil.copy(originalStream, tempDocx);
158 convertDocxToPdf(tempDocx, tempPdf);
159 originalPageCount = getPdfPageCount(tempPdf);
160
161 } finally {
162 AthUtil.deleteFile(tempPdf);
163 AthUtil.deleteFile(tempDocx);
164 }
165
166 } else if (filter instanceof AthPdfFilter) {
167
168 originalPdf = AthUtil.createTempFile(".pdf");
169 StreamUtil.copy(originalStream, originalPdf);
170 originalPageCount = getPdfPageCount(originalPdf);
171 isPdf = true;
172
173 } else {
174
175 Log.warn(DocxPageMatcher.class,
176 "Unknown filter type - returning the translation file without adjustment");
177
178 return translation;
179 }
180
181
182
183
184
185
186
187 Log.info(DocxPageMatcher.class, "Getting translation page count");
188 translationPdf = AthUtil.createTempFile(".pdf");
189 convertDocxToPdf(translation, translationPdf);
190 translationPageCount = getPdfPageCount(translationPdf);
191
192 if (translationPageCount <= originalPageCount) {
193 Log.info(DocxPageMatcher.class,
194 "Translation page count acceptable ({} ≤ {}). No adjustment needed.",
195 translationPageCount, originalPageCount);
196
197 return isPdf ? translationPdf : translation;
198 }
199
200
201 Log.info(DocxPageMatcher.class,
202 "Starting page matching: current {} pages → target ≤ {} pages",
203 translationPageCount, originalPageCount);
204
205
206 AdjustmentResult result = shrinkFontToFitPagesAdaptive(translation, originalPageCount);
207 int finalPages;
208
209 if (result != null) {
210 finalPages = result.pageCount;
211
212 Log.info(DocxPageMatcher.class, "✓ SUCCESS: Achieved {} pages with delta {}",
213 finalPages, result.delta);
214
215 AthUtil.deleteFile(translationPdf);
216 Log.info(DocxPageMatcher.class, "Returning final PDF with {} pages", finalPages);
217 return isPdf ? result.pdfFile : result.docxFile;
218
219 } else {
220 Log.warn(DocxPageMatcher.class,
221 "Failed to reach target after {} sets (max delta -{}): Final {} pages",
222 MAX_SETS, MAX_SETS * DELTAS_PER_SET, translationPageCount);
223
224 return isPdf ? translationPdf : translation;
225 }
226
227 } finally {
228
229 if (translationPdf != null && tempPdfs != null) {
230 for (File tempPdf : tempPdfs) {
231 if (!translationPdf.equals(tempPdf)) {
232 AthUtil.deleteFile(tempPdf);
233 }
234 }
235 }
236
237 AthUtil.deleteFile(originalPdf);
238 }
239
240 }
241
242 private static AdjustmentResult shrinkFontToFitPagesAdaptive(File originalDocx, int targetPages)
243 throws Exception {
244
245 AdjustmentResult best = null;
246 boolean targetReached = false;
247
248 for (int set = 1; set <= MAX_SETS; set++) {
249 double startDelta = -1.0 * (set * DELTAS_PER_SET - 2);
250 double endDelta = startDelta - (DELTAS_PER_SET - 1);
251
252 Log.info(DocxPageMatcher.class, "Trying delta set {}: {} to {}", set, startDelta, endDelta);
253
254 AdjustmentResult setResult = tryDeltaSet(originalDocx, targetPages, startDelta, endDelta);
255
256 if (setResult != null) {
257 if (best == null || setResult.delta > best.delta) {
258 targetReached = setResult.pageCount <= targetPages;
259
260 if (best != null) {
261 cleanupAdjustmentResult(best);
262 }
263
264 best = setResult;
265 }
266 }
267
268 if (setResult != null && targetReached) {
269 Log.info(DocxPageMatcher.class, "Target reached in set {} with delta {} – stopping",
270 set, setResult.delta);
271
272 break;
273 }
274 }
275
276 if (best != null) {
277 Log.info(DocxPageMatcher.class,
278 "✓ Best overall result: Delta {} → {} pages – applying to original file",
279 best.delta, best.pageCount);
280
281 adjustStylesFontSize(originalDocx.getAbsolutePath(), best.delta);
282 adjustDocumentFontSize(originalDocx.getAbsolutePath(), best.delta);
283
284 cleanupAdjustmentResult(best);
285 return new AdjustmentResult(originalDocx, best.pageCount, best.delta, best.pdfFile);
286 }
287
288 return null;
289 }
290
291 private static AdjustmentResult tryDeltaSet(File originalDocx, int targetPages,
292 double startDelta, double endDelta) throws Exception {
293
294 List<Callable<AdjustmentResult>> tasks = new ArrayList<>();
295 ExecutorService executor = Executors.newFixedThreadPool(NUM_PARALLEL_THREADS);
296
297 for (double delta = startDelta; delta >= endDelta; delta -= 1.0) {
298 final double currentDelta = delta;
299
300 tasks.add(() -> {
301 File tempCopy = AthUtil.createTempFile(".docx");
302
303 try {
304 Files.copy(originalDocx.toPath(), tempCopy.toPath(), StandardCopyOption.REPLACE_EXISTING);
305
306 adjustStylesFontSize(tempCopy.getAbsolutePath(), currentDelta);
307 adjustDocumentFontSize(tempCopy.getAbsolutePath(), currentDelta);
308
309 Log.info(DocxPageMatcher.class, "Getting page count for Delta {}", currentDelta);
310 File tempPdf = AthUtil.createTempFile(".pdf");
311 convertDocxToPdf(tempCopy, tempPdf);
312
313 int pageCount = getPdfPageCount(tempPdf);
314 Log.info(DocxPageMatcher.class, "Delta {} → {} pages", currentDelta, pageCount);
315
316 return new AdjustmentResult(tempCopy, pageCount, currentDelta, tempPdf);
317
318 } catch (Exception e) {
319 if (tempCopy.exists()) {
320 tempCopy.delete();
321 }
322
323 throw e;
324 }
325 });
326 }
327
328 AdjustmentResult bestInSet = null;
329
330 try {
331 List<Future<AdjustmentResult>> futures = executor.invokeAll(tasks);
332
333 for (Future<AdjustmentResult> future : futures) {
334 try {
335 AdjustmentResult result = future.get();
336
337 if (result.pageCount <= targetPages) {
338
339 if (bestInSet == null || Math.abs(result.delta) < Math.abs(bestInSet.delta)) {
340 if (bestInSet != null) {
341 cleanupAdjustmentResult(bestInSet);
342 }
343
344 bestInSet = result;
345
346 } else {
347 cleanupAdjustmentResult(result);
348 }
349
350 } else {
351
352 cleanupAdjustmentResult(result);
353 }
354
355 } catch (Exception e) {
356 Log.error(DocxPageMatcher.class, "Task execution failed: {}", e.getMessage());
357 }
358 }
359
360 } finally {
361 executor.shutdownNow();
362 }
363
364 return bestInSet;
365 }
366
367 private static void cleanupAdjustmentResult(AdjustmentResult result) {
368 if (result == null)
369 return;
370
371 if (result.docxFile != null && result.docxFile.exists()) {
372 if (!result.docxFile.delete())
373 result.docxFile.deleteOnExit();
374 }
375 }
376
377 public static int getPdfPageCount(InputStream pdfStream) throws Exception {
378 File tempPdf = AthUtil.createTempFile(".pdf");
379
380 try {
381 StreamUtil.copy(pdfStream, tempPdf);
382 return getPdfPageCount(tempPdf);
383
384 } finally {
385 if (tempPdf.exists()) {
386 tempPdf.delete();
387 }
388 }
389 }
390
391 public static int getPdfPageCount(File pdfFile) throws Exception {
392 try (PDDocument pdfDoc = Loader.loadPDF(pdfFile)) {
393 return pdfDoc.getNumberOfPages();
394 }
395 }
396
397 public static int getDocxPageCount(File docxFile, boolean byPageBreaks) throws Exception {
398 if (!byPageBreaks) {
399 return getDocxPageCount(docxFile);
400 }
401
402 Path tempDir = Files.createTempDirectory("docx_");
403
404 try {
405 AthUtil.unzip(docxFile.getAbsolutePath(), tempDir.toString());
406
407 Path documentPath = tempDir.resolve("word/document.xml");
408
409 if (!Files.exists(documentPath)) {
410 Log.warn(DocxPageMatcher.class, "document.xml not found in DOCX");
411 return 0;
412 }
413
414 Log.info(DocxPageMatcher.class, "Counting manual page breaks in document.xml...");
415
416 String content = Files.readString(documentPath);
417
418
419
420 Pattern pageBreakPattern = Pattern.compile("<w:br\\s+w:type=\"page\"");
421 Matcher matcher = pageBreakPattern.matcher(content);
422
423 int pageBreakCount = 0;
424
425 while (matcher.find()) {
426 pageBreakCount++;
427 }
428
429
430 int pageCount = pageBreakCount + 1;
431
432 Log.info(DocxPageMatcher.class, "Found {} page breaks, total pages: {}",
433 pageBreakCount, pageCount);
434
435 return pageCount;
436
437 } finally {
438 AthUtil.deleteDirectory(tempDir.toFile());
439 }
440 }
441
442 public static int getDocxPageCount(File docxFile) throws Exception {
443 File tempPdf = AthUtil.createTempFile(".pdf");
444
445 try {
446 convertDocxToPdf(docxFile, tempPdf);
447 return getPdfPageCount(tempPdf);
448
449 } finally {
450 AthUtil.deleteFile(tempPdf);
451 }
452 }
453
454 public static void convertToPdf(File docxFile, OutputStream output) throws Exception {
455 File tempPdf = AthUtil.createTempFile(".pdf");
456
457 try {
458 convertDocxToPdf(docxFile, tempPdf);
459 StreamUtil.copy(tempPdf, output);
460
461 } finally {
462 if (tempPdf.exists())
463 tempPdf.delete();
464 }
465 }
466
467 private static void convertDocxToPdf(File docxFile, File pdfFile) throws Exception {
468 Map<String, Object> pdfProps = new HashMap<>();
469
470 pdfProps.put("EmbedStandardFonts", true);
471 pdfProps.put("SelectPdfVersion", 1);
472 pdfProps.put("ExportFormFields", false);
473
474 LocalConverter.builder()
475 .officeManager(officeManager)
476 .storeProperties(pdfProps)
477 .build()
478 .convert(docxFile)
479 .as(DOCX)
480 .to(pdfFile)
481 .as(PDF)
482 .execute();
483 }
484
485 private static void adjustStylesFontSize(String docxPath, double delta) throws Exception {
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530 }
531
532 private static void adjustDocumentFontSize(String docxPath, double delta) throws Exception {
533 Path tempDir = Files.createTempDirectory("docx_");
534 AthUtil.unzip(docxPath, tempDir.toString());
535
536 Path documentPath = tempDir.resolve("word/document.xml");
537
538 if (!Files.exists(documentPath)) {
539 Log.warn(DocxPageMatcher.class, "document.xml not found");
540 AthUtil.deleteDirectory(tempDir.toFile());
541 return;
542 }
543
544 Log.info(DocxPageMatcher.class, "Modifying font size entries in document.xml...");
545
546 String content = Files.readString(documentPath);
547 Matcher matcher = FONT_SIZE_PATTERN.matcher(content);
548 StringBuffer sb = new StringBuffer();
549 AtomicInteger modified = new AtomicInteger(0);
550
551 while (matcher.find()) {
552 String valStr = matcher.group(2);
553
554 try {
555 double currentHalfPoints = Double.parseDouble(valStr);
556 double currentPoints = currentHalfPoints / 2.0;
557 double newPoints = Math.max(1.0, currentPoints + delta);
558 int newHalfPoints = (int) (newPoints * 2);
559
560 matcher.appendReplacement(sb, matcher.group(1) + newHalfPoints + matcher.group(3));
561 modified.incrementAndGet();
562
563 } catch (NumberFormatException e) {
564 matcher.appendReplacement(sb, matcher.group(0));
565 }
566 }
567
568 matcher.appendTail(sb);
569
570 Log.info(DocxPageMatcher.class, "Modified {} font size entries in document.xml (regex)",
571 modified.get());
572
573 Files.writeString(documentPath, sb.toString());
574
575 AthUtil.zip(tempDir.toString(), docxPath);
576 AthUtil.deleteDirectory(tempDir.toFile());
577 }
578 }