package com.megatim.manuel.core.convert; import org.apache.poi.xwpf.model.XWPFHeaderFooterPolicy; import org.apache.poi.xwpf.usermodel.*; import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTBookmark; import java.io.FileInputStream; import java.io.IOException; import java.nio.file.Path; import java.util.ArrayList; import java.util.HashSet; import java.util.List; import java.util.Set; /** * Word .docx (XWPF) -> HTML, en conservant gras/italique/souligné/barré, couleur, police, * taille, alignement, titres (Heading/Titre 1..6 -> h1..h6), listes, tableaux, images, liens. */ public class DocxConverter extends AbstractHtmlConverter { public DocxConverter(Path outputDir) { super(outputDir); } @Override public ConversionResult convert(Path source, int order) throws IOException { String safe = safeBase(source, order); String htmlName = safe + ".html"; List headings = new ArrayList<>(); List figures = new ArrayList<>(); List landmarks = new ArrayList<>(); Set recordedFm = new HashSet<>(); List images = new ArrayList<>(); StringBuilder body = new StringBuilder(); int[] headingCounter = {0}; int[] figureCounter = {0}; int[] lmCounter = {0}; int[] imgCounter = {0}; try (FileInputStream fis = new FileInputStream(source.toFile()); XWPFDocument doc = new XWPFDocument(fis)) { body.append(renderHeaderFooter(doc, true, images, imgCounter, safe)); boolean inList = false; for (IBodyElement be : doc.getBodyElements()) { if (be instanceof XWPFParagraph) { XWPFParagraph p = (XWPFParagraph) be; boolean isList = p.getNumID() != null; if (isList && !inList) { body.append("
    \n"); inList = true; } if (!isList && inList) { body.append("
\n"); inList = false; } renderParagraph(doc, p, body, headings, headingCounter, figures, figureCounter, images, imgCounter, safe, isList, landmarks, recordedFm, lmCounter); } else if (be instanceof XWPFTable) { if (inList) { body.append("\n"); inList = false; } renderTable((XWPFTable) be, body, images, imgCounter, safe, landmarks, recordedFm, lmCounter); } } if (inList) body.append("\n"); body.append(renderHeaderFooter(doc, false, images, imgCounter, safe)); } // Titre du document = nom du fichier (identifie le document, sans le confondre avec son chapitre 1) String title = clean(stripExt(source.getFileName().toString())); writeHtml(htmlName, title, body.toString()); ConversionResult result = new ConversionResult(htmlName, title, headings, images, figures); result.landmarks.addAll(landmarks); return result; } private void renderParagraph(XWPFDocument doc, XWPFParagraph p, StringBuilder out, List headings, int[] hc, List figures, int[] fc, List images, int[] ic, String safe, boolean isList, List landmarks, Set recordedFm, int[] lc) throws IOException { int level = headingLevel(doc, p); String styleName = styleName(doc, p); String text = p.getText(); // Signets Word (_Toc..., renvois) -> ancres HTML, pour rendre cliquables sommaire et références String marks = bookmarkAnchors(p); String inner = marks + renderRuns(p, images, ic, safe); String align = alignOf(p); // Pages liminaires (Résumé, Mots clés, Historique, Diffusion, Sommaire, Table des illustrations) String fmKey = FrontMatter.match(text); String lmAnchor = null; boolean excludeFromTree = false; if (fmKey != null && !recordedFm.contains(fmKey)) { recordedFm.add(fmKey); excludeFromTree = true; // évite le doublon dans le Sommaire lmAnchor = safe + "_lm" + (++lc[0]); if (FrontMatter.isEntry(fmKey)) landmarks.add(new Heading(FrontMatter.label(fmKey), 0, lmAnchor)); } if (level > 0) { String anchor = lmAnchor != null ? lmAnchor : safe + "_h" + (++hc[0]); if (!excludeFromTree && !text.isBlank()) headings.add(new Heading(clean(text), level, anchor)); out.append("").append(inner).append("\n"); } else if (isCaptionStyle(p.getStyleID(), styleName)) { // Légende d'illustration -> entrée de la « Table des illustrations » String anchor = lmAnchor != null ? lmAnchor : safe + "_f" + (++fc[0]); if (lmAnchor == null && !text.isBlank()) figures.add(new Heading(clean(text), 1, anchor)); out.append("

").append(inner.isEmpty() ? " " : inner).append("

\n"); } else if (isList) { String idAttr = lmAnchor != null ? " id=\"" + lmAnchor + "\"" : ""; out.append("").append(inner.isEmpty() ? " " : inner).append("\n"); } else if (lmAnchor != null) { out.append("

") .append(inner.isEmpty() ? " " : inner).append("

\n"); } else if (inner.trim().isEmpty()) { out.append("

 

\n"); } else { out.append("").append(inner).append("

\n"); } } /** Émet une ancre HTML invisible par signet Word du paragraphe (cible des liens internes). */ private String bookmarkAnchors(XWPFParagraph p) { StringBuilder sb = new StringBuilder(); for (CTBookmark b : p.getCTP().getBookmarkStartList()) { String name = b.getName(); if (name == null || name.isEmpty() || "_GoBack".equals(name)) continue; sb.append(""); } return sb.toString(); } /** Nettoie un libellé de sommaire : espaces insécables, tabulations et espaces multiples. */ private static String clean(String s) { if (s == null) return ""; return s.replace(' ', ' ').replace(' ', ' ').replace(' ', ' ') .trim().replaceAll("\\s+", " "); } private boolean isCaptionStyle(String id, String name) { String s = ((id == null ? "" : id) + " " + (name == null ? "" : name)).toLowerCase(); // Exclure les listes auto-générées (table des illustrations / table of figures) if (s.contains("table") || s.contains("toc") || s.contains("tabledesillustrations")) return false; return s.contains("caption") || s.contains("legend") || s.contains("lgende") || s.contains("légend"); } private String styleName(XWPFDocument doc, XWPFParagraph p) { String id = p.getStyleID(); if (id != null && doc.getStyles() != null) { XWPFStyle st = doc.getStyles().getStyle(id); if (st != null) return st.getName(); } return null; } /** Rend l'en-tête (isHeader=true) ou le pied de page (false) par défaut du document. */ private String renderHeaderFooter(XWPFDocument doc, boolean isHeader, List images, int[] ic, String safe) throws IOException { XWPFHeaderFooterPolicy policy = doc.getHeaderFooterPolicy(); if (policy == null) return ""; XWPFHeaderFooter hf = isHeader ? policy.getDefaultHeader() : policy.getDefaultFooter(); if (hf == null) return ""; StringBuilder sb = new StringBuilder(); for (XWPFParagraph p : hf.getParagraphs()) { String inner = renderRuns(p, images, ic, safe); if (!inner.trim().isEmpty()) { sb.append("").append(inner).append("

\n"); } } if (sb.length() == 0) return ""; String cls = isHeader ? "page-header" : "page-footer"; return "
\n" + sb + "
\n"; } private void renderTable(XWPFTable t, StringBuilder out, List images, int[] ic, String safe, List landmarks, Set recordedFm, int[] lc) throws IOException { out.append("\n"); for (XWPFTableRow row : t.getRows()) { out.append(""); for (XWPFTableCell cell : row.getTableCells()) { out.append(""); } out.append("\n"); } out.append("
"); for (XWPFParagraph p : cell.getParagraphs()) { String inner = renderRuns(p, images, ic, safe); // Repère liminaire trouvé dans une cellule (page de garde sous forme de tableau) String fmKey = FrontMatter.match(p.getText()); String id = ""; if (fmKey != null && !recordedFm.contains(fmKey)) { recordedFm.add(fmKey); String a = safe + "_lm" + (++lc[0]); id = " id=\"" + a + "\""; if (FrontMatter.isEntry(fmKey)) landmarks.add(new Heading(FrontMatter.label(fmKey), 0, a)); } out.append("") .append(inner.isEmpty() ? " " : inner).append(""); } out.append("
\n"); } private String renderRuns(XWPFParagraph p, List images, int[] ic, String safe) throws IOException { StringBuilder sb = new StringBuilder(); for (IRunElement ire : p.getIRuns()) { if (ire instanceof XWPFHyperlinkRun) { XWPFHyperlinkRun hr = (XWPFHyperlinkRun) ire; String content = renderRun(hr, images, ic, safe); XWPFHyperlink link = hr.getHyperlink(p.getDocument()); String url = link != null ? link.getURL() : null; String anchor = hr.getAnchor(); if (url != null && !url.isEmpty()) { sb.append("").append(content).append(""); } else if (anchor != null && !anchor.isEmpty()) { // Lien interne vers un signet (sommaire, renvoi de pagination) -> ancre HTML sb.append("").append(content).append(""); } else { sb.append(content); } } else if (ire instanceof XWPFRun) { sb.append(renderRun((XWPFRun) ire, images, ic, safe)); } } return sb.toString(); } private String renderRun(XWPFRun r, List images, int[] ic, String safe) throws IOException { StringBuilder style = new StringBuilder(); String color = r.getColor(); if (color != null && color.matches("[0-9A-Fa-f]{6}")) { style.append("color:#").append(color).append(';'); } String font = r.getFontFamily(); if (font != null && !font.isEmpty()) { style.append("font-family:'").append(font.replace("'", "")).append("';"); } int size = r.getFontSize(); if (size > 0) { style.append("font-size:").append(size).append("pt;"); } UnderlinePatterns ul = r.getUnderline(); boolean underline = ul != null && ul != UnderlinePatterns.NONE; boolean strike = r.isStrikeThrough(); StringBuilder open = new StringBuilder(); StringBuilder close = new StringBuilder(); if (r.isBold()) { open.append(""); close.insert(0, ""); } if (r.isItalic()) { open.append(""); close.insert(0, ""); } if (underline) { open.append(""); close.insert(0, ""); } if (strike) { open.append(""); close.insert(0, ""); } StringBuilder imgs = new StringBuilder(); for (XWPFPicture pic : r.getEmbeddedPictures()) { XWPFPictureData data = pic.getPictureData(); if (data == null) continue; String ext = data.suggestFileExtension(); if (ext == null || ext.isEmpty()) ext = "png"; String name = writeImageBytes(safe, ++ic[0], ext, data.getData()); images.add(name); imgs.append("\"\"/"); } String content = esc(r.text()) + imgs; if (content.isEmpty()) return ""; String span = style.length() > 0 ? "" + content + "" : content; return open + span + close.toString(); } private int headingLevel(XWPFDocument doc, XWPFParagraph p) { String id = p.getStyleID(); int lv = matchHeading(id); if (lv == 0) lv = matchHeading(styleName(doc, p)); if (lv > 0) return lv; // Repli : niveau hiérarchique Word (outline level), porté par le paragraphe ou son style. // C'est ce qui alimente réellement le sommaire, même avec des styles de titres personnalisés. return outlineLevel(doc, p); } private int outlineLevel(XWPFDocument doc, XWPFParagraph p) { try { if (p.getCTP().isSetPPr() && p.getCTP().getPPr().isSetOutlineLvl()) { return clampOutline(p.getCTP().getPPr().getOutlineLvl().getVal().intValue()); } } catch (Exception ignore) { /* pas de niveau direct */ } try { String id = p.getStyleID(); if (id != null && doc.getStyles() != null) { XWPFStyle st = doc.getStyles().getStyle(id); if (st != null && st.getCTStyle() != null && st.getCTStyle().isSetPPr() && st.getCTStyle().getPPr().isSetOutlineLvl()) { return clampOutline(st.getCTStyle().getPPr().getOutlineLvl().getVal().intValue()); } } } catch (Exception ignore) { /* pas de niveau dans le style */ } return 0; } /** outlineLvl Word : 0..8 = titres 1..9 (9 = corps de texte -> ignoré). On plafonne à h6. */ private int clampOutline(int val) { if (val < 0 || val > 8) return 0; return Math.min(val + 1, 6); } private String alignOf(XWPFParagraph p) { ParagraphAlignment a = p.getAlignment(); if (a == null) return ""; switch (a) { case CENTER: return " style=\"text-align:center\""; case RIGHT: return " style=\"text-align:right\""; case BOTH: return " style=\"text-align:justify\""; default: return ""; } } }