View Javadoc
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; // Max delta = -12
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     * Matches page count and returns the final PDF file (always PDF).
92     * 
93     * @param originalStream  - the original document stream (PDF)
94     * @param translationDocx - the translated DOCX file to be adjusted
95     * @return File - PDF file with matched page count (caller is responsible for cleanup)
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       // Adobe PDF Services insert manual page breaks in the converted DOCX, so we count target
110       // pages by parsing the translated DOCX file
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    * Matches page count and returns the PDF or DOCX final translation file.
132    * 
133    * @param originalStream - the original document stream (PDF or DOCX)
134    * @param translation    - the translated DOCX file to be adjusted
135    * @param filter         - the filter type (OpenXMLFilter or AthPdfFilter)
136    * @return File - PDF/DOCX file with matched page count (caller is responsible for cleanup)
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         // Save the PDF so we can reuse it instead of converting multiple times
168         originalPdf = AthUtil.createTempFile(".pdf");
169         StreamUtil.copy(originalStream, originalPdf);
170         originalPageCount = getPdfPageCount(originalPdf);
171         isPdf = true;
172 
173       } else {
174         // Unknown format - just return the passed translation file
175         Log.warn(DocxPageMatcher.class,
176             "Unknown filter type - returning the translation file without adjustment");
177 
178         return translation;
179       }
180 
181       // // Set the DOCX file page size/orientation and margins based on the original PDF
182       // if (isPdf) {
183       // PageSetupConverter.copyPageSetupFromPdfToDocx(originalPdf, translation);
184       // }
185 
186       // Get translation page count (convert DOCX -> PDF to count pages)
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       // Translation has too many pages - need to shrink fonts
201       Log.info(DocxPageMatcher.class,
202           "Starting page matching: current {} pages → target ≤ {} pages",
203           translationPageCount, originalPageCount);
204 
205       // Perform font adjustment
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       // Clean up PDFs if we created ones
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); // -1, -4, -7, -10
250       double endDelta = startDelta - (DELTAS_PER_SET - 1); // -3, -6, -9, -12
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             // Prefer smallest delta (least aggressive) among acceptable results
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             // Not acceptable, clean up
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       // Pattern to match page breaks: <w:br w:type="page"/>
419       // Handles both <w:br w:type="page"/> and <w:br w:type="page" />
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       // Number of pages = number of page breaks + 1 (first page before any break)
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     // Path tempDir = Files.createTempDirectory("docx_");
487     // unzip(docxPath, tempDir.toString());
488     //
489     // Path stylesPath = tempDir.resolve("word/styles.xml");
490     //
491     // if (!Files.exists(stylesPath)) {
492     // Log.warn(DocxPageMatcher.class, "styles.xml not found in DOCX");
493     // deleteDirectory(tempDir.toFile());
494     // return;
495     // }
496     //
497     // Log.info(DocxPageMatcher.class, "Modifying font size entries in styles.xml...");
498     //
499     // String content = Files.readString(stylesPath);
500     // Matcher matcher = FONT_SIZE_PATTERN.matcher(content);
501     // StringBuffer sb = new StringBuffer();
502     // AtomicInteger modified = new AtomicInteger(0);
503     //
504     // while (matcher.find()) {
505     // String valStr = matcher.group(2);
506     //
507     // try {
508     // double currentHalfPoints = Double.parseDouble(valStr);
509     // double currentPoints = currentHalfPoints / 2.0;
510     // double newPoints = Math.max(1.0, currentPoints + delta);
511     // int newHalfPoints = (int) (newPoints * 2);
512     //
513     // matcher.appendReplacement(sb, matcher.group(1) + newHalfPoints + matcher.group(3));
514     // modified.incrementAndGet();
515     //
516     // } catch (NumberFormatException e) {
517     // matcher.appendReplacement(sb, matcher.group(0));
518     // }
519     // }
520     //
521     // matcher.appendTail(sb);
522     //
523     // Log.info(DocxPageMatcher.class, "Modified {} font size entries in styles.xml (regex)",
524     // modified.get());
525     //
526     // Files.writeString(stylesPath, sb.toString());
527     //
528     // zip(tempDir.toString(), docxPath);
529     // deleteDirectory(tempDir.toFile());
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 }