View Javadoc
1   package com.acumenvelocity.ath.pagematcher;
2   
3   import java.io.File;
4   import java.io.FileInputStream;
5   import java.io.FileOutputStream;
6   import java.io.IOException;
7   import java.io.UncheckedIOException;
8   import java.nio.file.Files;
9   import java.nio.file.Path;
10  import java.nio.file.Paths;
11  import java.nio.file.StandardCopyOption;
12  import java.util.concurrent.atomic.AtomicInteger;
13  import java.util.zip.ZipEntry;
14  import java.util.zip.ZipInputStream;
15  import java.util.zip.ZipOutputStream;
16  
17  import javax.xml.parsers.DocumentBuilder;
18  import javax.xml.parsers.DocumentBuilderFactory;
19  import javax.xml.transform.OutputKeys;
20  import javax.xml.transform.Transformer;
21  import javax.xml.transform.TransformerFactory;
22  import javax.xml.transform.dom.DOMSource;
23  import javax.xml.transform.stream.StreamResult;
24  
25  import org.apache.pdfbox.Loader;
26  import org.apache.pdfbox.pdmodel.PDDocument;
27  import org.jodconverter.local.LocalConverter;
28  import org.jodconverter.local.office.LocalOfficeManager;
29  import org.w3c.dom.Document;
30  import org.w3c.dom.Element;
31  import org.w3c.dom.NodeList;
32  
33  public class DocxPageMatcherTest {
34  
35    private static final String WORD_NS = "http://schemas.openxmlformats.org/wordprocessingml/2006/main";
36    private static final LocalOfficeManager officeManager = LocalOfficeManager.builder().build();
37  
38    public static void main(String[] args) throws Exception {
39      if (args.length != 2) {
40        System.out.println("Usage: java DocxPageMatcher <original.docx> <translation.docx>");
41        return;
42      }
43  
44      String originalPath = args[0];
45      String translationPath = args[1];
46  
47      startOfficeManager();
48      try {
49        // if (!isLibreOfficeAvailable()) {
50        // System.err.println("LibreOffice UNO API is not available.\n\n"
51        // + "sudo apt-get install -y --no-install-recommends \\\n"
52        // + " libreoffice \\\n"
53        // + " libreoffice-writer \\\n"
54        // + " libreoffice-java-common \\\n"
55        // + " ure \\\n"
56        // + " fonts-dejavu fonts-liberation fonts-crosextra-carlito fonts-crosextra-caladea
57        // fontconfig");
58        //
59        // return;
60        // }
61  
62        System.out.println("Starting page count matching process...");
63        matchPageCount(originalPath, translationPath);
64  
65      } finally {
66        stopOfficeManager();
67      }
68    }
69  
70    /**
71     * Translation DOCX is modified by the method.
72     * @param originalDocxPath
73     * @param translationDocxPath
74     * @throws Exception
75     */
76    public static void matchPageCount(String originalDocxPath, String translationDocxPath)
77        throws Exception {
78      int originalPages = getPageCount(originalDocxPath);
79      int translationPages = getPageCount(translationDocxPath);
80  
81      System.out.printf("Original pages: %d%n", originalPages);
82      System.out.printf("Translation pages: %d%n", translationPages);
83  
84      if (translationPages <= originalPages) {
85        System.out.println("Translation page count is acceptable. No changes needed.");
86        return;
87      }
88  
89      // Phase 1: Try adjusting styles.xml
90      System.out.println("\nPhase 1: Attempting to adjust styles.xml...");
91      boolean stylesSuccess = shrinkFontToFitPages(translationDocxPath, originalPages, true);
92  
93      if (!stylesSuccess) {
94        // Phase 2: Try adjusting document.xml
95        System.out.println("\nPhase 2: Adjusting document.xml...");
96        shrinkFontToFitPages(translationDocxPath, originalPages, false);
97      }
98  
99      int finalPages = getPageCount(translationDocxPath);
100     System.out.printf("\nFinal result: %d pages (target: %d)%n", finalPages, originalPages);
101   }
102 
103   private static boolean shrinkFontToFitPages(String docxPath, int targetPages, boolean useStyles)
104       throws Exception {
105 
106     String backupPath = docxPath + ".backup";
107 
108     // Create initial backup (in case we need to revert)
109     Files.copy(Paths.get(docxPath), Paths.get(backupPath), StandardCopyOption.REPLACE_EXISTING);
110 
111     int currentPages = getPageCount(docxPath);
112     System.out.printf("Initial page count: %d (target: %d)%n", currentPages, targetPages);
113 
114     if (currentPages <= targetPages) {
115       System.out.println("No adjustment needed – page count already acceptable.");
116       Files.delete(Paths.get(backupPath));
117       return true;
118     }
119 
120     int iteration = 0;
121     double totalDelta = 0.0;
122 
123     while (currentPages > targetPages) {
124       iteration++;
125       double deltaThisStep = -1.0; // Decrease font size by 1 point
126       totalDelta += deltaThisStep;
127 
128       System.out.printf("Iteration %d: Applying delta=%.1fpt (total delta=%.1fpt)%n",
129           iteration, deltaThisStep, totalDelta);
130 
131       // Restore from backup before each adjustment to apply cumulative delta correctly
132       Files.copy(Paths.get(backupPath), Paths.get(docxPath), StandardCopyOption.REPLACE_EXISTING);
133 
134       // Apply the cumulative delta
135       if (useStyles) {
136         adjustStylesFontSize(docxPath, totalDelta);
137 
138       } else {
139         adjustDocumentFontSize(docxPath, totalDelta);
140       }
141 
142       currentPages = getPageCount(docxPath);
143       System.out.printf("  Result: %d pages (target: %d)%n", currentPages, targetPages);
144 
145       if (currentPages <= targetPages) {
146         System.out.println("  ✓ Target page count achieved or exceeded!");
147         Files.delete(Paths.get(backupPath));
148         return true;
149       }
150 
151       // Safety limit to prevent infinite loop or excessive shrinking
152       if (iteration >= 10) {
153         System.out.println("  Maximum iterations reached. Stopping to avoid over-shrinking.");
154         break;
155       }
156     }
157 
158     // If we exited loop without reaching target, apply the best (last) result
159     if (currentPages > targetPages) {
160       System.out.println("Could not reach target page count. Applying the best result so far.");
161       // Already applied in last iteration, just clean up backup
162     }
163 
164     Files.delete(Paths.get(backupPath));
165     return currentPages <= targetPages;
166   }
167 
168   private static void adjustStylesFontSize(String docxPath, double delta) throws Exception {
169     Path tempDir = Files.createTempDirectory("docx_");
170     unzip(docxPath, tempDir.toString());
171 
172     Path stylesPath = tempDir.resolve("word/styles.xml");
173     if (!Files.exists(stylesPath)) {
174       System.out.println("styles.xml not found");
175       return;
176     }
177 
178     DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance();
179     factory.setNamespaceAware(true);
180     DocumentBuilder builder = factory.newDocumentBuilder();
181     Document doc = builder.parse(stylesPath.toFile());
182 
183     NodeList szNodes = doc.getElementsByTagNameNS(WORD_NS, "sz");
184     NodeList szCsNodes = doc.getElementsByTagNameNS(WORD_NS, "szCs");
185 
186     AtomicInteger modified = new AtomicInteger(0);
187 
188     java.util.function.Consumer<Element> adjustFontSize = szElement -> {
189       String valAttr = szElement.getAttribute("w:val");
190       if (!valAttr.isEmpty()) {
191         try {
192           double currentSize = Double.parseDouble(valAttr) / 2.0; // half-points → points
193           double newSize = Math.max(1.0, currentSize + delta);
194           szElement.setAttribute("w:val", String.valueOf((int) (newSize * 2)));
195           modified.incrementAndGet();
196         } catch (NumberFormatException e) {
197           // Skip invalid values
198         }
199       }
200     };
201 
202     // Process all <w:sz> elements
203     for (int i = 0; i < szNodes.getLength(); i++) {
204       adjustFontSize.accept((Element) szNodes.item(i));
205     }
206 
207     // Process all <w:szCs> elements (complex script font sizes)
208     for (int i = 0; i < szCsNodes.getLength(); i++) {
209       adjustFontSize.accept((Element) szCsNodes.item(i));
210     }
211 
212     System.out.printf("  Modified %d font size entries (sz + szCs) in styles.xml%n",
213         modified.get());
214 
215     TransformerFactory transformerFactory = TransformerFactory.newInstance();
216     Transformer transformer = transformerFactory.newTransformer();
217     transformer.setOutputProperty(OutputKeys.INDENT, "yes");
218     transformer.transform(new DOMSource(doc), new StreamResult(stylesPath.toFile()));
219 
220     zip(tempDir.toString(), docxPath);
221     deleteDirectory(tempDir.toFile());
222   }
223 
224   private static void adjustDocumentFontSize(String docxPath, double delta) throws Exception {
225     Path tempDir = Files.createTempDirectory("docx_");
226     unzip(docxPath, tempDir.toString());
227 
228     Path documentPath = tempDir.resolve("word/document.xml");
229     if (!Files.exists(documentPath)) {
230       System.out.println("document.xml not found");
231       return;
232     }
233 
234     DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance();
235     factory.setNamespaceAware(true);
236     DocumentBuilder builder = factory.newDocumentBuilder();
237     Document doc = builder.parse(documentPath.toFile());
238 
239     NodeList szNodes = doc.getElementsByTagNameNS(WORD_NS, "sz");
240     NodeList szCsNodes = doc.getElementsByTagNameNS(WORD_NS, "szCs");
241 
242     AtomicInteger modified = new AtomicInteger(0);
243 
244     java.util.function.Consumer<Element> adjustFontSize = szElement -> {
245       String valAttr = szElement.getAttribute("w:val");
246       if (!valAttr.isEmpty()) {
247         try {
248           double currentSize = Double.parseDouble(valAttr) / 2.0; // half-points → points
249           double newSize = Math.max(1.0, currentSize + delta);
250 
251           szElement.setAttribute("w:val", String.valueOf((int) (newSize * 2)));
252           modified.incrementAndGet();
253 
254         } catch (NumberFormatException e) {
255           // Skip invalid values
256         }
257       }
258     };
259 
260     // Process all <w:sz> elements
261     for (int i = 0; i < szNodes.getLength(); i++) {
262       adjustFontSize.accept((Element) szNodes.item(i));
263     }
264 
265     // Process all <w:szCs> elements (complex script font sizes)
266     for (int i = 0; i < szCsNodes.getLength(); i++) {
267       adjustFontSize.accept((Element) szCsNodes.item(i));
268     }
269 
270     System.out.printf("Modified %d font size entries (sz + szCs) in document.xml%n",
271         modified.get());
272 
273     TransformerFactory transformerFactory = TransformerFactory.newInstance();
274     Transformer transformer = transformerFactory.newTransformer();
275     transformer.setOutputProperty(OutputKeys.INDENT, "yes");
276     transformer.transform(new DOMSource(doc), new StreamResult(documentPath.toFile()));
277 
278     zip(tempDir.toString(), docxPath);
279     deleteDirectory(tempDir.toFile());
280   }
281 
282   private static int getPageCount(String docxPath) throws Exception {
283     // try (FileInputStream fis = new FileInputStream(docxPath);
284     // XWPFDocument document = new XWPFDocument(fis)) {
285     //
286     // int pages = document.getProperties().getExtendedProperties().getUnderlyingProperties()
287     // .getPages();
288     //
289     // if (pages == 0) {
290     // // Fallback: estimate based on content
291     // pages = Math.max(1, document.getParagraphs().size() / 30);
292     // }
293     //
294     // return pages;
295     // }
296 
297     return getAccuratePageCount(docxPath);
298   }
299 
300   // public static boolean isLibreOfficeAvailable() {
301   // LocalOfficeManager officeManager = null;
302   // try {
303   // // Auto-detects LibreOffice at /usr/lib/libreoffice on Ubuntu
304   // // If auto-detection ever fails, uncomment the line below:
305   // // officeManager = LocalOfficeManager.builder().officeHome("/usr/lib/libreoffice").build();
306   // officeManager = LocalOfficeManager.builder().build();
307   //
308   // officeManager.start(); // This will throw if soffice not found or unusable
309   //
310   // return true;
311   //
312   // } catch (OfficeException e) {
313   // System.err.println("LibreOffice not available: " + e.getMessage());
314   // return false;
315   //
316   // } finally {
317   // if (officeManager != null && officeManager.isRunning()) {
318   // try {
319   // officeManager.stop();
320   // } catch (OfficeException ignored) {
321   // }
322   // }
323   // }
324   // }
325 
326   public static void startOfficeManager() {
327     try {
328       officeManager.start();
329       System.out.println("LibreOffice manager started successfully");
330     } catch (Exception e) {
331       // Optional: retry a few times
332       e.printStackTrace();
333     }
334   }
335 
336   public static void stopOfficeManager() {
337     try {
338       officeManager.stop();
339       System.out.println("LibreOffice manager stopped successfully");
340 
341     } catch (Exception e) {
342       // Optional: retry a few times
343       e.printStackTrace();
344     }
345   }
346 
347   // private static int getAccuratePageCount(String docxPath) throws Exception {
348   // // Bootstrap local LibreOffice context
349   // com.sun.star.uno.XComponentContext xContext = com.sun.star.comp.helper.Bootstrap.bootstrap();
350   //
351   // // Get the Desktop service (XComponentLoader)
352   // Object desktop = xContext.getServiceManager().createInstanceWithContext(
353   // "com.sun.star.frame.Desktop", xContext);
354   // XComponentLoader xLoader = (XComponentLoader) UnoRuntime.queryInterface(
355   // XComponentLoader.class, desktop);
356   //
357   // // Prepare load properties: Hidden = true (load without UI)
358   // PropertyValue[] loadProps = new PropertyValue[1];
359   // loadProps[0] = new PropertyValue();
360   // loadProps[0].Name = "Hidden";
361   // loadProps[0].Value = Boolean.TRUE;
362   //
363   // // Convert path to file URL
364   // String fileUrl = "file:///" + new java.io.File(docxPath).getAbsolutePath()
365   // .replace("\\", "/");
366   //
367   // // Load the document
368   // XComponent xComponent = xLoader.loadComponentFromURL(
369   // fileUrl, "_blank", 0, loadProps);
370   //
371   // try {
372   // // Get document properties
373   // XDocumentPropertiesSupplier supplier = (XDocumentPropertiesSupplier) UnoRuntime
374   // .queryInterface(XDocumentPropertiesSupplier.class, xComponent);
375   // XDocumentProperties props = supplier.getDocumentProperties();
376   //
377   // // Get statistics (includes accurate PageCount after layout)
378   // NamedValue[] stats = props.getDocumentStatistics();
379   // for (NamedValue stat : stats) {
380   // if ("PageCount".equals(stat.Name)) {
381   // // Value is usually Short or Integer
382   // return ((Number) stat.Value).intValue();
383   // }
384   // }
385   // } finally {
386   // // Always dispose the component
387   // xComponent.dispose();
388   // }
389   //
390   // // Fallback if not found (should not happen)
391   // return -1;
392   // }
393 
394   private static int getAccuratePageCount(String docxPath) throws Exception {
395     File inputFile = new File(docxPath);
396     File tempPdf = File.createTempFile("pagecount_", ".pdf");
397 
398     try {
399       // Build the converter explicitly using your shared officeManager
400       LocalConverter.builder()
401           .officeManager(officeManager)
402           .build()
403           .convert(inputFile)
404           .to(tempPdf)
405           .execute();
406 
407       try (PDDocument pdfDoc = Loader.loadPDF(tempPdf)) {
408         return pdfDoc.getNumberOfPages();
409       }
410 
411     } finally {
412       if (tempPdf.exists() && !tempPdf.delete()) {
413         tempPdf.deleteOnExit(); // Safety net
414       }
415     }
416   }
417 
418   private static void unzip(String zipFilePath, String destDir) throws IOException {
419     File dir = new File(destDir);
420     if (!dir.exists())
421       dir.mkdirs();
422 
423     try (ZipInputStream zis = new ZipInputStream(new FileInputStream(zipFilePath))) {
424       ZipEntry entry;
425       while ((entry = zis.getNextEntry()) != null) {
426         File file = new File(destDir, entry.getName());
427         if (entry.isDirectory()) {
428           file.mkdirs();
429         } else {
430           file.getParentFile().mkdirs();
431           try (FileOutputStream fos = new FileOutputStream(file)) {
432             byte[] buffer = new byte[4096];
433             int len;
434             while ((len = zis.read(buffer)) > 0) {
435               fos.write(buffer, 0, len);
436             }
437           }
438         }
439         zis.closeEntry();
440       }
441     }
442   }
443 
444   private static void zip(String sourceDirPath, String zipFilePath) throws IOException {
445     Path source = Paths.get(sourceDirPath);
446     try (ZipOutputStream zos = new ZipOutputStream(new FileOutputStream(zipFilePath))) {
447       Files.walk(source).filter(path -> !Files.isDirectory(path)).forEach(path -> {
448         ZipEntry entry = new ZipEntry(source.relativize(path).toString().replace("\\", "/"));
449         try {
450           zos.putNextEntry(entry);
451           Files.copy(path, zos);
452           zos.closeEntry();
453         } catch (IOException e) {
454           throw new UncheckedIOException(e);
455         }
456       });
457     }
458   }
459 
460   private static void deleteDirectory(File dir) {
461     File[] files = dir.listFiles();
462     if (files != null) {
463       for (File file : files) {
464         if (file.isDirectory()) {
465           deleteDirectory(file);
466         } else {
467           file.delete();
468         }
469       }
470     }
471     dir.delete();
472   }
473 }