fix: DOCX sections, box offsets and cell margins round clean

- a continuous break that only encodes a columns region no longer
  fabricates a section break or its own header/footer set
- a topBottom text box keeps its x offset (posOffset now written)
- cell margins read back in hundredth-cm, so twips don't show through
- a table margin's zero side stays the default instead of -0
This commit is contained in:
Steffen Becker committed 2026-08-16 12:50:10 +02:00
1 parent d32d40be3c
commit db3fbe32c1
2 files changed
+19 -7

No files matched your search

+2 -1
View File
@@ -1137,7 +1137,8 @@ function textBoxDrawingXml(box: TextBoxDocx, index: number, parts: TxbxParts): s
: `<wp:wrapSquare wrapText="${box.wrap === 'right' ? 'left' : 'right'}"/>`;
const align = box.wrap === 'right' ? 'right' : 'left';
const emu = (cm: number) => Math.round(cm * 360000);
const posH = box.offsetCm != null && box.wrap !== 'topBottom'
// The x rides topBottom too: it moves the frame within its full-width band.
const posH = box.offsetCm != null
? `<wp:posOffset>${emu(box.offsetCm)}</wp:posOffset>`
: `<wp:align>${align}</wp:align>`;
return (
+17 -6
View File
@@ -201,18 +201,25 @@ export function importDocx(bytes: Uint8Array, convertedImages: ConvertedImages =
const { groups, midSectPrs } = splitBodySections(Array.from(body.children));
const finalSectPr = fc(body, 'sectPr');
const blocks: Node[] = [];
const groupCols = groups.map((g) => sectPrColumns(g.sectPr ?? finalSectPr, ctx));
// A continuous break where the column setup changes only encodes a columns region
// (the export splits there without opening a section): no section break, and the
// group shares the previous one's header/footer set.
const colsOnly = groups.map((g, gi) => gi > 0
&& !sectionStartsNewPage(g.sectPr ?? finalSectPr)
&& JSON.stringify(groupCols[gi]) !== JSON.stringify(groupCols[gi - 1]));
groups.forEach((g, gi) => {
const inner = convertBlocks(g.els, ctx, 'body');
// A section's own w:type says how it begins: a page-starting break (nextPage/odd/even,
// or the default) puts its first block on a new page; continuous/nextColumn flow on.
if (gi > 0 && inner.length) {
if (gi > 0 && !colsOnly[gi] && inner.length) {
const first = inner[0];
if (first.type === 'paragraph' || first.type === 'heading') {
first.attrs = { ...(first.attrs ?? {}), sectionBreak: true };
if (sectionStartsNewPage(g.sectPr ?? finalSectPr)) first.attrs.breakBefore = 'page';
}
}
const cols = sectPrColumns(g.sectPr ?? finalSectPr, ctx);
const cols = groupCols[gi];
if (cols) pushColumnRuns(inner, cols, blocks, ctx);
else blocks.push(...inner);
});
@@ -239,7 +246,8 @@ export function importDocx(bytes: Uint8Array, convertedImages: ConvertedImages =
// The paper is the *first* section's, as the margins are: a document whose last
// section is one landscape page is not a landscape document.
const docPaper = sectPaper(groups[0]?.sectPr ?? finalSectPr);
const hfSections = sectionHfSets(groups.map((g) => g.sectPr ?? finalSectPr), ctx, oddEven, docPaper);
const hfSections = sectionHfSets(
groups.filter((_, gi) => !colsOnly[gi]).map((g) => g.sectPr ?? finalSectPr), ctx, oddEven, docPaper);
const first = hfSections[0];
// A first-page/even zone reserves the header/footer band even when its default is empty;
// the distance is document-wide, so any section having one is enough.
@@ -2215,6 +2223,7 @@ function convertTable(tbl: Element, ctx: Ctx): Node | null {
// A w:tblCellMar / w:tcMar's four sides in cm, taking the first element that declares
// each. w:type="nil" is an explicit zero; a side nobody declares comes back null.
// Hundredth-cm: a twip is 0.0018cm, so an authored round2 value reads back exactly.
function cellMarginsCm(els: (Element | null)[]): (number | null)[] {
return [['top'], ['right', 'end'], ['bottom'], ['left', 'start']].map((names) => {
for (const el of els) {
@@ -2223,7 +2232,7 @@ function cellMarginsCm(els: (Element | null)[]): (number | null)[] {
if (!s) continue;
if (s.getAttributeNS(W, 'type') === 'nil') return 0;
const w = intAttr(s, W, 'w');
if (w != null) return twipToCm(w);
if (w != null) return round2(twipToCm(w));
}
}
return null;
@@ -2371,7 +2380,7 @@ function docxTableLook(tblPr: Element | null): string | null {
// w:tblW) its width — the rest becomes the editor's right margin. Under the older
// compatibility mode the indent is measured to the cell's *text*, so the table hangs its
// left cell margin into the page margin (tblIndIsToText).
function tableMargins(tbl: Element, weights: number[] | null, ctx: Ctx, leftPadCm: number): { marginLeft: number; marginRight: number } | null {
function tableMargins(tbl: Element, weights: number[] | null, ctx: Ctx, leftPadCm: number): { marginLeft?: number; marginRight?: number } | null {
const tblPr = fc(tbl, 'tblPr');
const content = ctx.contentWidthCm;
const dxa = (el: Element | null) => (el?.getAttributeNS(W, 'type') ?? 'dxa') === 'dxa' ? intAttr(el, W, 'w') : null;
@@ -2385,7 +2394,9 @@ function tableMargins(tbl: Element, weights: number[] | null, ctx: Ctx, leftPadC
if (Math.abs(left) < 0.05 && Math.abs(right) < 0.05) return null;
if (left + right > content - 1) return null;
const round2 = (v: number) => Math.round(v * 100) / 100;
return { marginLeft: round2(left), marginRight: round2(right) };
const l = round2(left), r = round2(right);
// A zero side is the attr's default (null); rounding may also leave a -0 behind.
return { ...(Math.abs(l) >= 0.005 ? { marginLeft: l } : {}), ...(Math.abs(r) >= 0.005 ? { marginRight: r } : {}) };
}
function flattenTable(tbl: Element, ctx: Ctx): Node[] {