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<Heading> headings = new ArrayList<>();
|
List<Heading> figures = new ArrayList<>();
|
List<Heading> landmarks = new ArrayList<>();
|
Set<String> recordedFm = new HashSet<>();
|
List<String> 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("<ul>\n"); inList = true; }
|
if (!isList && inList) { body.append("</ul>\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("</ul>\n"); inList = false; }
|
renderTable((XWPFTable) be, body, images, imgCounter, safe, landmarks, recordedFm, lmCounter);
|
}
|
}
|
if (inList) body.append("</ul>\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<Heading> headings, int[] hc,
|
List<Heading> figures, int[] fc,
|
List<String> images, int[] ic, String safe, boolean isList,
|
List<Heading> landmarks, Set<String> 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("<h").append(level).append(" id=\"").append(anchor).append("\"").append(align)
|
.append(">").append(inner).append("</h").append(level).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("<p class=\"caption\" id=\"").append(anchor).append("\"").append(align)
|
.append(">").append(inner.isEmpty() ? " " : inner).append("</p>\n");
|
} else if (isList) {
|
String idAttr = lmAnchor != null ? " id=\"" + lmAnchor + "\"" : "";
|
out.append("<li").append(idAttr).append(align).append(">").append(inner.isEmpty() ? " " : inner).append("</li>\n");
|
} else if (lmAnchor != null) {
|
out.append("<p id=\"").append(lmAnchor).append("\"").append(align).append(">")
|
.append(inner.isEmpty() ? " " : inner).append("</p>\n");
|
} else if (inner.trim().isEmpty()) {
|
out.append("<p class=\"sp\"> </p>\n");
|
} else {
|
out.append("<p").append(align).append(">").append(inner).append("</p>\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("<a id=\"").append(esc(name)).append("\"></a>");
|
}
|
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<String> 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("<p").append(alignOf(p)).append(">").append(inner).append("</p>\n");
|
}
|
}
|
if (sb.length() == 0) return "";
|
String cls = isHeader ? "page-header" : "page-footer";
|
return "<div class=\"" + cls + "\">\n" + sb + "</div>\n";
|
}
|
|
private void renderTable(XWPFTable t, StringBuilder out,
|
List<String> images, int[] ic, String safe,
|
List<Heading> landmarks, Set<String> recordedFm, int[] lc) throws IOException {
|
out.append("<table class=\"wt\">\n");
|
for (XWPFTableRow row : t.getRows()) {
|
out.append("<tr>");
|
for (XWPFTableCell cell : row.getTableCells()) {
|
out.append("<td>");
|
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("<div").append(id).append(alignOf(p)).append(">")
|
.append(inner.isEmpty() ? " " : inner).append("</div>");
|
}
|
out.append("</td>");
|
}
|
out.append("</tr>\n");
|
}
|
out.append("</table>\n");
|
}
|
|
private String renderRuns(XWPFParagraph p, List<String> 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("<a href=\"").append(esc(url)).append("\">").append(content).append("</a>");
|
} else if (anchor != null && !anchor.isEmpty()) {
|
// Lien interne vers un signet (sommaire, renvoi de pagination) -> ancre HTML
|
sb.append("<a href=\"#").append(esc(anchor)).append("\">").append(content).append("</a>");
|
} 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<String> 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("<b>"); close.insert(0, "</b>"); }
|
if (r.isItalic()) { open.append("<i>"); close.insert(0, "</i>"); }
|
if (underline) { open.append("<u>"); close.insert(0, "</u>"); }
|
if (strike) { open.append("<s>"); close.insert(0, "</s>"); }
|
|
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("<img src=\"").append(name).append("\" alt=\"\"/>");
|
}
|
|
String content = esc(r.text()) + imgs;
|
if (content.isEmpty()) return "";
|
|
String span = style.length() > 0
|
? "<span style=\"" + style + "\">" + content + "</span>"
|
: 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 "";
|
}
|
}
|
}
|