From b80440de0ef316b8b88482122c666c33e3b4b2ac Mon Sep 17 00:00:00 2001 From: Steffen Becker Date: Thu, 3 Sep 2026 00:50:02 +0200 Subject: [PATCH] fix: a chapter number is set and placed the way the file describes it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The outline level's character style, its indent and the stop its label's tab runs to were never read, so a 96pt grey chapter number rendered at the heading's own size and everything under it sat ~30mm high. The label is an inline block now — one set larger than its heading grows the line as both products grow it — hung out of the paragraph's indent with the level's stop as its minimum width. text-indent inherits into that block and would shrink it by exactly what it hangs out, so the label zeroes it. Both formats carry all of it back out (ODF's label alignment, Word's w:suff / w:ind / w:rPr). --- src/lib/export/docx.ts | 28 ++++++++++--- src/lib/export/odt.ts | 23 ++++++++++- src/lib/import/docx.ts | 11 ++++- src/lib/import/docxStyles.ts | 18 ++++++++- src/lib/import/odt.ts | 47 ++++++++++++++++++++-- src/lib/styles/CLAUDE.md | 9 +++++ src/lib/styles/outlineNumbering.ts | 58 ++++++++++++++++++++++----- src/lib/styles/styleSheet.ts | 2 +- tests/unit/outline-numbering.test.ts | 60 ++++++++++++++++++++++++++-- 9 files changed, 229 insertions(+), 27 deletions(-) diff --git a/src/lib/export/docx.ts b/src/lib/export/docx.ts index 366f2fd..5aa22eb 100644 --- a/src/lib/export/docx.ts +++ b/src/lib/export/docx.ts @@ -4,7 +4,7 @@ import { TableOfContents, Table, TableRow, TableCell, Header, Footer, PageNumber, SimpleField, ImportedXmlComponent, CommentRangeStart, CommentRangeEnd, CommentReference, InsertedTextRun, DeletedTextRun, - AlignmentType, LevelFormat, UnderlineType, BorderStyle, ShadingType, + AlignmentType, LevelFormat, LevelSuffix, UnderlineType, BorderStyle, ShadingType, WidthType, HeightRule, PageOrientation, LineRuleType, LineNumberRestartFormat, TableLayoutType, SectionType, NumberFormat, HorizontalPositionAlign, VerticalPositionRelativeFrom, HorizontalPositionRelativeFrom, TextWrappingType, TextWrappingSide, TabStopType, LeaderType, @@ -345,12 +345,26 @@ class Numbering { const from = Math.max(1, l + 1 - Math.max(1, def.displayLevels) + 1); const chain = []; for (let k = from; k <= l + 1; k++) if (outline[k - 1]?.format !== 'none') chain.push(`%${k}`); + // The label's own run and the indent it hangs out of; a level with no stop to + // run to is followed by nothing, its gap already part of the label text. + const run = def.labelText ? runPropsOf(def.labelText) : {}; + const indent: Writable = {}; + if (def.indentCm) indent.left = cmToTwip(def.indentCm); + if (def.firstIndentCm) { + if (def.firstIndentCm < 0) indent.hanging = cmToTwip(-def.firstIndentCm); + else indent.firstLine = cmToTwip(def.firstIndentCm); + } + const style: { run?: IRunStylePropertiesOptions; paragraph?: { indent: IIndentAttributesProperties } } = {}; + if (Object.keys(run).length) style.run = run; + if (Object.keys(indent).length) style.paragraph = { indent }; levels.push({ level: l, format: ORDERED_FORMAT[def.format] ?? LevelFormat.DECIMAL, text: `${def.prefix}${chain.join('.')}${def.suffix || ' '}`, start: def.start ?? 1, alignment: AlignmentType.LEFT, + suffix: def.tabCm == null ? LevelSuffix.NOTHING : LevelSuffix.TAB, + ...(Object.keys(style).length ? { style } : {}), }); } this.map.set(reference, levels); @@ -2542,9 +2556,8 @@ function paragraphStyleOf(style: Style): IParagraphStyleOptions { }; } -// One registry character style → a Word character style (w:type="character"). -function characterStyleOf(style: Style): ICharacterStyleOptions { - const t = style.text; +// A run of text the model describes → Word's run properties. +function runPropsOf(t: TextProps): Writable { const run: Writable = {}; if (t.fontFamily) run.font = t.fontFamily === 'Liberation Serif' ? DOC_FONT : t.fontFamily; if (t.fontSizePt != null) run.size = Math.round(t.fontSizePt * 2); @@ -2555,7 +2568,12 @@ function characterStyleOf(style: Style): ICharacterStyleOptions { if (t.underline) run.underline = {}; if (t.strike) run.strike = t.strike; if (t.color) run.color = t.color.replace('#', ''); - return { id: docxStyleId(style.name), name: style.name, quickFormat: true, run }; + return run; +} + +// One registry character style → a Word character style (w:type="character"). +function characterStyleOf(style: Style): ICharacterStyleOptions { + return { id: docxStyleId(style.name), name: style.name, quickFormat: true, run: runPropsOf(style.text) }; } // Word needs a referenced table style to exist. ODF/our model hold the banding, and the diff --git a/src/lib/export/odt.ts b/src/lib/export/odt.ts index 691002a..e827dd7 100644 --- a/src/lib/export/odt.ts +++ b/src/lib/export/odt.ts @@ -17,7 +17,7 @@ import { TABLE_REGIONS, parseTableLook, regionText, type TableLook, type TableRegion, } from '../styles/tableStyles'; import { effectiveListLevel, listStyleMarginCm, listStyleOverridden, MAX_LIST_LEVELS, type ListStyle } from '../styles/listStyles'; -import { outlineIsEmpty, type OutlineNumbering } from '../styles/outlineNumbering'; +import { outlineIsEmpty, type OutlineLevel, type OutlineNumbering } from '../styles/outlineNumbering'; // A table's named style plus the conditional areas it opts into (Word's Table Style // Options), collected by exportTable in document order. @@ -1984,11 +1984,30 @@ function outlineStyleXml(outline: OutlineNumbering | null | undefined): string { if (level.displayLevels > 1) attrs['text:display-levels'] = String(level.displayLevels); if (level.start !== 1) attrs['text:start-value'] = String(level.start); if (level.charStyle) attrs['text:style-name'] = odfStyleName(level.charStyle); - levels.push(setTagAttrs('', attrs)); + const tag = setTagAttrs('', attrs); + const pos = outlineLevelPositionXml(level); + levels.push(pos ? `${tag.replace('/>', '>')}${pos}` : tag); }); return levels.length ? `${levels.join('')}` : ''; } +// Where the level puts its label — the label-alignment mode both products write today. +// A level the file gave no position keeps none, so nothing is invented for it. +function outlineLevelPositionXml(level: OutlineLevel): string { + const indent = level.indentCm ?? 0; + const first = level.firstIndentCm ?? 0; + const tab = level.tabCm ?? null; + if (!indent && !first && tab == null) return ''; + const attrs: Record = { + 'text:label-followed-by': tab == null ? 'nothing' : 'listtab', + 'fo:text-indent': `${first}cm`, + 'fo:margin-left': `${indent}cm`, + }; + if (tab != null) attrs['text:list-tab-stop-position'] = `${tab}cm`; + return '' + + `${setTagAttrs('', attrs)}`; +} + // Resolve STY sentinels: point each marked block at its named style — directly when it // carries no direct formatting, else via a clone of its automatic style whose parent // becomes the named one (so hard formatting keeps overriding the style, as in LO). diff --git a/src/lib/import/docx.ts b/src/lib/import/docx.ts index adbadcf..c7b4c9b 100644 --- a/src/lib/import/docx.ts +++ b/src/lib/import/docx.ts @@ -937,12 +937,21 @@ function outlineFromDocx(ctx: Ctx): OutlineNumbering | null { const text = def.lvlText ?? ''; const holders = [...text.matchAll(/%\d/g)]; const last = holders[holders.length - 1]; + // The label's own run, and where Word hangs it: the level's indent is the stop its + // tab runs to, the first line the place the label itself starts. + const labelText = def.run ? runTextProps(def.run) : null; + const indentCm = def.leftTwip != null ? round2(twipToCm(def.leftTwip)) : 0; + const firstCm = def.hangingTwip != null ? round2(-twipToCm(def.hangingTwip)) : 0; out.push({ format, prefix: holders.length ? text.slice(0, holders[0].index) : '', - suffix: last ? text.slice((last.index ?? 0) + 2) : '', + suffix: (last ? text.slice((last.index ?? 0) + 2) : '') + (def.suffix === 'space' ? ' ' : ''), displayLevels: Math.max(1, holders.length), start: def.start ?? 1, + ...(labelText && Object.keys(labelText).length ? { labelText } : {}), + ...(indentCm ? { indentCm } : {}), + ...(firstCm ? { firstIndentCm: firstCm } : {}), + ...(def.suffix === 'space' || def.suffix === 'nothing' ? {} : { tabCm: indentCm }), }); } // Trailing unnumbered levels carry nothing — a definition ends at its last number. diff --git a/src/lib/import/docxStyles.ts b/src/lib/import/docxStyles.ts index 2ed9508..f7d1718 100644 --- a/src/lib/import/docxStyles.ts +++ b/src/lib/import/docxStyles.ts @@ -44,7 +44,10 @@ export type RunProps = { // A numbering level definition (numbering.xml w:lvl). bulletFont is the level's // w:rPr/w:rFonts (Wingdings/Symbol give the bullet glyph its meaning). -export type LevelDef = { numFmt?: string; lvlText?: string; leftTwip?: number; start?: number; bulletFont?: string; rightAligned?: boolean }; +export type LevelDef = { + numFmt?: string; lvlText?: string; leftTwip?: number; hangingTwip?: number; start?: number; + bulletFont?: string; rightAligned?: boolean; suffix?: string; run?: RunProps; +}; // Paragraph spacing from a w:pPr/w:spacing (only the attributes actually present, so // omitted ones inherit up the style chain). before/after in twips, line per w:line. @@ -365,7 +368,18 @@ export class DocxStyles { const txt = firstChild(lvl, 'lvlText'); if (txt) def.lvlText = wVal(txt) ?? undefined; const start = firstChild(lvl, 'start'); if (start) { const n = parseInt(wVal(start) ?? '', 10); if (Number.isFinite(n)) def.start = n; } const ind = firstChild(lvl, 'pPr') && firstChild(firstChild(lvl, 'pPr')!, 'ind'); - if (ind) { const l = parseInt(ind.getAttributeNS(W, 'left') ?? ind.getAttributeNS(W, 'start') ?? '', 10); if (Number.isFinite(l)) def.leftTwip = l; } + if (ind) { + const l = parseInt(ind.getAttributeNS(W, 'left') ?? ind.getAttributeNS(W, 'start') ?? '', 10); + if (Number.isFinite(l)) def.leftTwip = l; + // A first line past the indent is a negative hang, which is how Word writes a + // label sitting right of the text it labels. + const h = parseInt(ind.getAttributeNS(W, 'hanging') ?? '', 10); + const f = parseInt(ind.getAttributeNS(W, 'firstLine') ?? '', 10); + if (Number.isFinite(h)) def.hangingTwip = h; + else if (Number.isFinite(f)) def.hangingTwip = -f; + } + const suff = firstChild(lvl, 'suff'); if (suff) def.suffix = wVal(suff) ?? undefined; + const rPr = firstChild(lvl, 'rPr'); if (rPr) def.run = parseRunProps(rPr); const jc = firstChild(lvl, 'lvlJc'); if (jc && (wVal(jc) === 'right' || wVal(jc) === 'end')) def.rightAligned = true; const rf = firstChild(lvl, 'rPr') && firstChild(firstChild(lvl, 'rPr')!, 'rFonts'); diff --git a/src/lib/import/odt.ts b/src/lib/import/odt.ts index 02d5e75..cd76ae8 100644 --- a/src/lib/import/odt.ts +++ b/src/lib/import/odt.ts @@ -2,7 +2,7 @@ import { unzipSync, strFromU8 } from 'fflate'; import { StyleResolver, NS, WATERMARK_NAME, lengthToPt, lengthToCm, layerTextProps, type PropMap } from './styleResolver'; import { HEADING_STYLE_OVERRIDES, MAX_HEADING_LEVEL, ODF_LOOK_ATTRS, normalizeColor } from '../export/odt'; import { builtinStyleSheet, DEFAULT_STYLE, type ParaProps, type Style, type StyleSheet, type TextProps } from '../styles/styleSheet'; -import { DEFAULT_OUTLINE_LEVEL, MAX_OUTLINE_LEVELS, type OutlineNumbering } from '../styles/outlineNumbering'; +import { DEFAULT_OUTLINE_LEVEL, MAX_OUTLINE_LEVELS, type OutlineLevel, type OutlineNumbering } from '../styles/outlineNumbering'; import { LIST_LEVEL_STEP_CM, MAX_LIST_LEVELS, type ListLevelStyle, type ListStyle } from '../styles/listStyles'; import { HEADER_SHADE } from '../editor/extensions/tableHeaderRow'; import { fitInlineImage, framePx } from '../editor/extensions/image'; @@ -1534,6 +1534,12 @@ function collectStyleSheet(resolver: StyleResolver, ctx: Ctx): StyleSheet { if (level) style.outlineLevel = Number(level[1]); sheet.paragraph[name] = style; } + // A chapter number's character style is named by the outline definition, not by any + // run, so the walk never sees it — and the export would reference a style it dropped. + for (const level of Array.from(resolver.outlineStyle()?.children ?? [])) { + const named = level.getAttributeNS(NS.text, 'style-name'); + if (named) ctx.usedCharStyles.add(named); + } for (const odfName of ctx.usedCharStyles) { const name = ctx.charStyleNames.get(odfName) ?? odfName; const builtin = sheet.character[name]; @@ -1568,13 +1574,18 @@ function outlineFromOdf(el: Element | null, ctx: Ctx): OutlineNumbering | null { } numbered = true; const charStyle = def.getAttributeNS(NS.text, 'style-name'); + const name = charStyle ? ctx.charStyleNames.get(charStyle) ?? charStyle : null; + const labelText = charStyle ? textPropsFromOdf(ctx.resolver.spanTextProps(charStyle), ctx.resolver) : null; + const geometry = outlineLevelGeometry(def); out.push({ format: format as NoteNumFormat, prefix: def.getAttributeNS(NS.style, 'num-prefix') ?? '', - suffix: def.getAttributeNS(NS.style, 'num-suffix') ?? '', + suffix: (def.getAttributeNS(NS.style, 'num-suffix') ?? '') + geometry.pad, displayLevels: Math.max(1, parseInt(def.getAttributeNS(NS.text, 'display-levels') ?? '1', 10) || 1), start: Math.max(0, parseInt(def.getAttributeNS(NS.text, 'start-value') ?? '1', 10) || 1), - ...(charStyle ? { charStyle: ctx.charStyleNames.get(charStyle) ?? charStyle } : {}), + ...(name ? { charStyle: name } : {}), + ...(labelText && Object.keys(labelText).length ? { labelText } : {}), + ...geometry.pos, }); } // Trailing unnumbered levels carry nothing — a definition ends at its last number. @@ -1582,6 +1593,36 @@ function outlineFromOdf(el: Element | null, ctx: Ctx): OutlineNumbering | null { return numbered ? out : null; } +// Where a level puts its label: the newer label-alignment mode, else the positional one +// (text:space-before + text:min-label-width). A label followed by a space carries it in +// its suffix, since only a tab has a stop to run to. +function outlineLevelGeometry(def: Element): { pos: Partial; pad: string } { + const props = def.getElementsByTagNameNS(NS.style, 'list-level-properties')[0] ?? null; + const align = props?.getElementsByTagNameNS(NS.style, 'list-level-label-alignment')[0] ?? null; + const pos: Partial = {}; + let pad = ''; + const cm = (v: number | null) => (v == null ? null : Math.round(v * 1000) / 1000); + if (align) { + const indent = cm(lengthToCm(align.getAttributeNS(NS.fo, 'margin-left'))) ?? 0; + const first = cm(lengthToCm(align.getAttributeNS(NS.fo, 'text-indent'))) ?? 0; + const followedBy = align.getAttributeNS(NS.text, 'label-followed-by') ?? 'listtab'; + if (indent) pos.indentCm = indent; + if (first) pos.firstIndentCm = first; + if (followedBy === 'listtab') { + pos.tabCm = cm(lengthToCm(align.getAttributeNS(NS.text, 'list-tab-stop-position'))) ?? indent; + } else if (followedBy === 'space') pad = ' '; + } else if (props) { + const before = cm(lengthToCm(props.getAttributeNS(NS.text, 'space-before'))) ?? 0; + const width = cm(lengthToCm(props.getAttributeNS(NS.text, 'min-label-width'))) ?? 0; + if (before || width) { + pos.indentCm = Math.round((before + width) * 1000) / 1000; + pos.firstIndentCm = -width; + pos.tabCm = pos.indentCm; + } + } + return { pos, pad }; +} + // A named element → the registry's shape. Each level's indentCm is // its margin step past the level above minus the 1.27cm base (the export's inverse). function listStyleFromOdf(name: string, el: Element, builtin?: boolean): ListStyle { diff --git a/src/lib/styles/CLAUDE.md b/src/lib/styles/CLAUDE.md index 802c36b..ec09daf 100644 --- a/src/lib/styles/CLAUDE.md +++ b/src/lib/styles/CLAUDE.md @@ -148,3 +148,12 @@ holds it as a reactive singleton persisted to `edentext-styles` (same shape as ` styles, Word as a multilevel numbering the heading styles point at (`w:numPr` on the style, `w:pStyle` in the level) — the DOCX pass mints that numbering's `w:num` itself, since no paragraph references it. + A level also carries **how the label is set and where it sits**: the character style + the file names (resolved into `labelText`, since Word's `w:lvl/w:rPr` names none), the + paragraph's indent, its first line and the stop the label's tab runs to. The label is + an *inline block* so one set larger than its heading grows the line as both products + grow it, with the stop as its minimum width; `text-indent` inherits into that block + and is zeroed there, or the hanging indent would shrink the label's own box by it. + Probed: a label wider than its stop overruns it, and LibreOffice then advances to the + paragraph's next tab stop (its own, else the 1.25cm grid, measured from the indent) — + the editor butts the title against the label instead, up to one grid step short. diff --git a/src/lib/styles/outlineNumbering.ts b/src/lib/styles/outlineNumbering.ts index 1c4acd3..17d5b37 100644 --- a/src/lib/styles/outlineNumbering.ts +++ b/src/lib/styles/outlineNumbering.ts @@ -3,6 +3,7 @@ // One definition per document, level 1 first (ODF text:outline-style). import type { NoteNumFormat } from '../storage/noteSettings'; +import type { TextProps } from './styleSheet'; export type OutlineLevel = { // 'none' = this level is unnumbered, which is how both products switch a level off. @@ -14,6 +15,15 @@ export type OutlineLevel = { start: number; // The character style the label takes, where the file names one. charStyle?: string | null; + // That style resolved, plus Word's w:lvl/w:rPr, which names no style: a chapter + // number is often set in a size and colour of its own. + labelText?: TextProps | null; + // Where the two products put label and title, cm from the text margin: the + // paragraph's indent, its first line relative to that (negative hangs the label + // out of it) and the stop the label's tab runs to. + indentCm?: number; + firstIndentCm?: number; + tabCm?: number | null; }; export type OutlineNumbering = OutlineLevel[]; @@ -40,6 +50,10 @@ const CSS_STYLE: Record = { const COUNTER = (level: number) => `edt-outline-${level}`; const quote = (s: string) => `"${s.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`; +// A heading in a cell, a list item or a frame is not part of the chapter count — +// neither product numbers one, so no rule below reaches it. +const LOOSE = ':not(:is(td, th, li, .frame-node) *)'; + // The label a heading shows, as the parts of a CSS `content` value. The parent levels a // display-levels chain shows are joined by '.', the way both products render one. function labelContent(outline: OutlineNumbering, level: number): string | null { @@ -56,7 +70,10 @@ function labelContent(outline: OutlineNumbering, level: number): string | null { parts.push(`counter(${COUNTER(l)}, ${CSS_STYLE[at.format]})`); } if (!parts.length) return null; - return [own.prefix ? quote(own.prefix) : '', ...parts, quote(own.suffix || ' ')] + // No-break spaces: a trailing one hangs at the end of the label's box and would take + // the gap the file puts between label and title out of the measurement. + const gap = (s: string) => quote(s.replace(/ /g, '\u00a0')); + return [own.prefix ? gap(own.prefix) : '', ...parts, gap(own.suffix || ' ')] .filter(Boolean).join(' '); } @@ -80,9 +97,14 @@ export function outlineLabel( return `${own.prefix}${parts.join('.')}${own.suffix || ' '}`; } +const cm = (v: number) => `${Math.round(v * 1000) / 1000}cm`; + // The counters that draw the labels. A heading increments its own level and resets every // deeper one, which is what makes 2.1.4 restart at every new chapter. -export function outlineCss(outline: OutlineNumbering | null | undefined): string { +export function outlineCss( + outline: OutlineNumbering | null | undefined, + labelDecls: (t: TextProps) => string[] = () => [], +): string { if (outlineIsEmpty(outline)) return ''; const levels = outline!; const rules: string[] = []; @@ -91,18 +113,34 @@ export function outlineCss(outline: OutlineNumbering | null | undefined): string .filter(Boolean).join(' '); rules.push(`.paper .tiptap {\n counter-reset: ${resets};\n}`); for (let level = 1; level <= Math.min(MAX_OUTLINE_LEVELS, levels.length); level++) { - if (levels[level - 1]?.format === 'none') continue; + const own = levels[level - 1]; + if (!own || own.format === 'none') continue; + const head = `.paper .tiptap h${level}${LOOSE}`; const deeper = levels .map((l, i) => (i + 1 > level && l.format !== 'none' ? `${COUNTER(i + 1)} ${Math.max(0, (l.start ?? 1) - 1)}` : '')) .filter(Boolean).join(' '); - rules.push(`.paper .tiptap h${level} {\n counter-increment: ${COUNTER(level)};${deeper ? `\n counter-reset: ${deeper};` : ''}\n}`); + const block = [`counter-increment: ${COUNTER(level)}`]; + if (deeper) block.push(`counter-reset: ${deeper}`); + // The label hangs out of the paragraph's indent, so a title that wraps lines up + // under itself — plus the section inset, which .tiptap's padding can't draw. + const indent = own.indentCm ?? 0; + const first = own.firstIndentCm ?? 0; + if (indent) block.push(`margin-left: calc(var(--sec-inset-left, 0px) + ${cm(indent)})`); + if (first) block.push(`text-indent: ${cm(first)}`); + rules.push(`${head} {\n ${block.join(';\n ')};\n}`); + const content = labelContent(levels, level); - if (content) rules.push(`.paper .tiptap h${level}::before {\n content: ${content};\n white-space: pre;\n}`); + if (!content) continue; + // An inline block, so a label set larger than its heading grows the line the way + // both products grow it; its min width is the tab the label is followed by, which + // a label wider than the stop simply overruns. + // text-indent inherits, and an inline block applies it to its own first line: the + // hanging indent below would shrink the label's box by exactly what it hangs it out. + const label = [`content: ${content}`, 'white-space: pre', 'display: inline-block', 'text-indent: 0']; + const gap = (own.tabCm ?? 0) - (indent + first); + if (gap > 0) label.push(`min-width: ${cm(gap)}`); + label.push(...labelDecls(own.labelText ?? {})); + rules.push(`${head}::before {\n ${label.join(';\n ')};\n}`); } - // A heading in a cell, a list item or a frame is not part of the chapter count — - // neither product numbers one. - const hs = Array.from({ length: MAX_OUTLINE_LEVELS }, (_, i) => `h${i + 1}`).join(', '); - rules.push(`.paper .tiptap :is(td, th, li, .frame-node) :is(${hs}) {\n counter-increment: none;\n}`); - rules.push(`.paper .tiptap :is(td, th, li, .frame-node) :is(${hs})::before {\n content: none;\n}`); return rules.join('\n\n'); } diff --git a/src/lib/styles/styleSheet.ts b/src/lib/styles/styleSheet.ts index 36fcafd..3a4b0eb 100644 --- a/src/lib/styles/styleSheet.ts +++ b/src/lib/styles/styleSheet.ts @@ -343,7 +343,7 @@ export function styleCss(sheet: StyleSheet): string { // Table styles last: their cell selectors must outrank the paragraph rules above. const table = tableStyleCss(sheet.table ?? {}); if (table) rules.push(table); - const outline = outlineCss(sheet.outline); + const outline = outlineCss(sheet.outline, (t) => textDeclarations(t)); if (outline) rules.push(outline); return rules.join('\n\n'); } diff --git a/tests/unit/outline-numbering.test.ts b/tests/unit/outline-numbering.test.ts index 35043cc..bd67cd9 100644 --- a/tests/unit/outline-numbering.test.ts +++ b/tests/unit/outline-numbering.test.ts @@ -4,7 +4,7 @@ import { importOdt } from '../../src/lib/import/odt'; import { importDocx } from '../../src/lib/import/docx'; import { buildOdt } from '../../src/lib/export/odt'; import { buildDocx } from '../../src/lib/export/docx'; -import { builtinStyleSheet } from '../../src/lib/styles/styleSheet'; +import { builtinStyleSheet, styleCss } from '../../src/lib/styles/styleSheet'; import { outlineCss, outlineLabel, type OutlineNumbering } from '../../src/lib/styles/outlineNumbering'; import { formatOrdinal } from '../../src/lib/utils/orderedListTypes'; @@ -36,6 +36,27 @@ function odtWithOutline(): Uint8Array { return zipSync({ 'content.xml': strToU8(content), 'styles.xml': strToU8(styles) }); } +// The shape LibreOffice writes for a chapter number of its own size: a character style +// on the level, and the label hung out of the paragraph's indent. +function odtWithGeometry(): Uint8Array { + const styles = ` + + + + + + + + + + `; + const content = ` + One + `; + return zipSync({ 'content.xml': strToU8(content), 'styles.xml': strToU8(styles) }); +} + const headingDoc = { type: 'doc', content: [1, 2, 3].map((level) => ({ @@ -77,8 +98,41 @@ describe('chapter numbering', () => { it('draws the label with counters that reset down the levels', () => { const css = outlineCss(outline); expect(css).toContain('counter-increment: edt-outline-1'); - expect(css).toMatch(/h2::before \{\n {2}content: counter\(edt-outline-1, decimal\) "\." counter\(edt-outline-2, decimal\) " ";/); + expect(css).toMatch(/h2[^{\n]*::before \{\n {2}content: counter\(edt-outline-1, decimal\) "\." counter\(edt-outline-2, decimal\) " ";/); // A heading in a cell or a list item is not part of the chapter count. - expect(css).toContain('counter-increment: none'); + expect(css).toContain(':not(:is(td, th, li, .frame-node) *)'); + }); + + it('reads the label position and the style it is set in', () => { + const level = importOdt(odtWithGeometry()).styles?.outline?.[0]; + expect(level?.indentCm).toBeCloseTo(0.762, 3); + expect(level?.firstIndentCm).toBeCloseTo(-0.762, 3); + expect(level?.tabCm).toBeCloseTo(0.762, 3); + expect(level?.charStyle).toBe('Chapter Number'); + expect(level?.labelText).toMatchObject({ fontSizePt: 96, color: '#B2B2B2' }); + }); + + it('hangs the label out of the indent, in the size the file gives it', () => { + const sheet = importOdt(odtWithGeometry()).styles!; + const css = styleCss(sheet); + expect(css).toMatch(/h1[^{]*\{\n[^}]*margin-left: calc\(var\(--sec-inset-left, 0px\) \+ 0\.762cm\)/); + expect(css).toMatch(/h1[^{]*\{\n[^}]*text-indent: -0\.762cm/); + // The stop the label's tab runs to is its minimum width; a wider label overruns it. + expect(css).toMatch(/h1[^{]*::before \{\n[^}]*min-width: 0\.762cm/); + expect(css).toMatch(/h1[^{]*::before \{\n[^}]*font-size: 96pt/); + }); + + it('carries the label position through both formats', async () => { + const sheet = { ...builtinStyleSheet(), outline: importOdt(odtWithGeometry()).styles!.outline }; + const odt = await buildOdt(headingDoc as never, margins, 'portrait', undefined, null, 'A4', sheet); + expect(strFromU8(unzipSync(odt)['styles.xml'])).toContain('text:list-tab-stop-position="0.762cm"'); + expect(importOdt(odt).styles?.outline?.[0]).toMatchObject({ indentCm: 0.762, firstIndentCm: -0.762, tabCm: 0.762 }); + + const docx = await buildDocx(headingDoc as never, margins, 'portrait', undefined, null, 'A4', sheet); + const back = importDocx(docx).styles?.outline?.[0]; + // Word's twips round the same tenth of a millimetre both ways. + expect(back?.indentCm).toBeCloseTo(0.762, 2); + expect(back?.firstIndentCm).toBeCloseTo(-0.762, 2); + expect(back?.labelText).toMatchObject({ fontSizePt: 96, color: '#B2B2B2' }); }); });