diff --git a/src/lib/editor/extensions.ts b/src/lib/editor/extensions.ts index 4c8629c..db9a5cf 100644 --- a/src/lib/editor/extensions.ts +++ b/src/lib/editor/extensions.ts @@ -31,6 +31,7 @@ import { ParagraphStyle } from './extensions/paragraphStyle'; import { CharacterStyle } from './extensions/characterStyle'; import { PageBreak } from './extensions/pageBreak'; import { Indent } from './extensions/indent'; +import { TabStops } from './extensions/tabStops'; import { FormattingMarks } from './extensions/formattingMarks'; import { SpellCheck } from './extensions/spellCheck'; import { SearchReplace } from './extensions/searchReplace'; @@ -108,6 +109,7 @@ export const extensions = [ CharacterStyle, PageBreak, Indent, + TabStops, Heading.configure({ levels: [1, 2, 3, 4, 5] }), BulletList, OrderedList, diff --git a/src/lib/editor/extensions/tabStops.ts b/src/lib/editor/extensions/tabStops.ts new file mode 100644 index 0000000..d93bfa4 --- /dev/null +++ b/src/lib/editor/extensions/tabStops.ts @@ -0,0 +1,277 @@ +import { Extension, type CommandProps } from '@tiptap/core'; +import { Plugin, PluginKey } from '@tiptap/pm/state'; +import { Decoration, DecorationSet, type EditorView } from '@tiptap/pm/view'; +import type { EditorState } from '@tiptap/pm/state'; +import type { Node as PmNode } from '@tiptap/pm/model'; +import { FORCE_PAGE_RECALC } from './pageBreaks'; +import { PX_PER_CM } from '../../storage/pageMargins'; + +// Per-paragraph tab stops. CSS only has the fixed `tab-size` grid, so a tab that +// resolves to a stop is measured and given the exact advance as an inline margin. + +export type TabAlign = 'left' | 'center' | 'right' | 'decimal'; +export type TabStop = { pos: number; align: TabAlign }; + +// The default grid a tab falls back to (editor.css `tab-size`), also LibreOffice's. +export const DEFAULT_TAB_CM = 1.25; + +const CODE: Record = { left: 'l', center: 'c', right: 'r', decimal: 'd' }; +const ALIGN: Record = { l: 'left', c: 'center', r: 'right', d: 'decimal' }; + +// Canonical attr form: one '' per stop, ';'-separated ('6c;12r;16d'). +// Positions are cm from the left text margin — the origin Word and ODF share. +export function parseTabStops(value: unknown): TabStop[] { + if (typeof value !== 'string' || !value) return []; + const out: TabStop[] = []; + for (const part of value.split(';')) { + const m = /^(-?\d*\.?\d+)([lcrd])$/.exec(part.trim()); + if (m) out.push({ pos: parseFloat(m[1]), align: ALIGN[m[2]] }); + } + return out.sort((a, b) => a.pos - b.pos); +} + +export function formatTabStops(stops: TabStop[]): string | null { + const byPos = new Map(); + for (const s of stops) { + const pos = Math.round(s.pos * 100) / 100; + if (pos >= 0) byPos.set(pos, s.align); + } + const out = [...byPos.entries()].sort((a, b) => a[0] - b[0]).map(([pos, align]) => `${pos}${CODE[align]}`); + return out.length ? out.join(';') : null; +} + +// The stops of the block holding the cursor, plus the block's own indents (cm) — +// everything the ruler needs to draw and edit one paragraph. +export function activeTabStops(state: EditorState): { stops: TabStop[]; indent: number; indentFirst: number } | null { + const $from = state.selection.$from; + for (let d = $from.depth; d >= 0; d--) { + const node = $from.node(d); + if (!node.isTextblock) continue; + return { + stops: parseTabStops(node.attrs.tabStops), + indent: typeof node.attrs.indent === 'number' ? node.attrs.indent : 0, + indentFirst: typeof node.attrs.indentFirst === 'number' ? node.attrs.indentFirst : 0, + }; + } + return null; +} + +declare module '@tiptap/core' { + interface Commands { + tabStops: { + setTabStops: (stops: TabStop[]) => ReturnType; + }; + } +} + +const tabStopsKey = new PluginKey('tabStops'); + +// A tab's advance is decided per line, so a block only needs measuring when it has +// stops of its own; a hanging indent implies one at the text position (Word). +function stopsOf(node: PmNode): TabStop[] { + const stops = parseTabStops(node.attrs.tabStops); + const first = typeof node.attrs.indentFirst === 'number' ? node.attrs.indentFirst : 0; + if (first < 0) { + const indent = typeof node.attrs.indent === 'number' ? node.attrs.indent : 0; + stops.push({ pos: indent, align: 'left' }); + stops.sort((a, b) => a.pos - b.pos); + } + return stops; +} + +// Doc positions of every tab character in a textblock. +function tabPositions(node: PmNode, blockPos: number): number[] { + const out: number[] = []; + node.forEach((child, offset) => { + const text = child.isText ? child.text ?? '' : ''; + for (let i = text.indexOf('\t'); i >= 0; i = text.indexOf('\t', i + 1)) { + out.push(blockPos + 1 + offset + i); + } + }); + return out; +} + +type TabWidth = { pos: number; width: number }; + +function measure(view: EditorView): TabWidth[] { + const dom = view.dom as HTMLElement; + const tipRect = dom.getBoundingClientRect(); + const scale = dom.offsetHeight ? tipRect.height / dom.offsetHeight : 1; + if (!scale) return []; + // x = 0 is the left text margin (.tiptap's padding edge), the origin of a stop. + const originX = tipRect.left + parseFloat(getComputedStyle(dom).paddingLeft || '0') * scale; + const out: TabWidth[] = []; + + view.state.doc.descendants((node, pos) => { + if (!node.isTextblock) return true; + const stops = stopsOf(node); + if (!stops.length) return false; + const tabs = tabPositions(node, pos); + const blockEnd = pos + node.nodeSize - 1; + + for (let t = 0; t < tabs.length; t++) { + const tabPos = tabs[t]; + const start = view.coordsAtPos(tabPos, 1); + const xCm = (start.left - originX) / scale / PX_PER_CM; + // Word: custom stops replace the default grid to their left; past the last one + // the CSS grid already does the right thing, so that tab stays undecorated. + const stop = stops.find((s) => s.pos > xCm + 0.01); + if (!stop) continue; + let width = (stop.pos - xCm) * PX_PER_CM; + + if (stop.align !== 'left') { + const segEnd = t + 1 < tabs.length ? tabs[t + 1] : blockEnd; + const segStart = view.coordsAtPos(tabPos + 1, 1); + const end = view.coordsAtPos(segEnd, -1); + // A segment that wraps can't satisfy the alignment — Word falls back to left. + if (segEnd > tabPos + 1 && Math.abs(end.top - start.top) < 1) { + let back = (end.left - segStart.left) / scale; + if (stop.align === 'center') back /= 2; + if (stop.align === 'decimal') { + const dec = decimalPos(node, pos, tabPos + 1, segEnd); + back = dec == null ? back : (view.coordsAtPos(dec, -1).left - segStart.left) / scale; + } + width -= back; + } + } + out.push({ pos: tabPos, width: Math.max(0, Math.round(width * 100) / 100) }); + } + return false; + }); + return out; +} + +// Position of the segment's decimal separator ('.' or ',' — the attr carries no +// style:char, so both locales' separators are accepted). +function decimalPos(node: PmNode, blockPos: number, from: number, to: number): number | null { + let found: number | null = null; + node.forEach((child, offset) => { + if (found != null || !child.isText) return; + const base = blockPos + 1 + offset; + const text = child.text ?? ''; + for (let i = 0; i < text.length; i++) { + const at = base + i; + if (at < from || at >= to) continue; + if (text[i] === '.' || text[i] === ',') { found = at; return; } + } + }); + return found; +} + +export const TabStops = Extension.create({ + name: 'tabStops', + + addOptions() { + return { types: ['paragraph', 'heading'] as string[] }; + }, + + addGlobalAttributes() { + return [ + { + types: this.options.types, + attributes: { + tabStops: { + default: null, + parseHTML: (element: HTMLElement) => element.getAttribute('data-tab-stops') || null, + renderHTML: (attributes: Record) => + (attributes.tabStops ? { 'data-tab-stops': String(attributes.tabStops) } : {}), + }, + }, + }, + ]; + }, + + addCommands() { + const types = this.options.types as string[]; + return { + setTabStops: (stops: TabStop[]) => ({ state, tr, dispatch }: CommandProps) => { + const { from, to } = state.selection; + const value = formatTabStops(stops); + let changed = false; + state.doc.nodesBetween(from, to, (node, pos) => { + if (!types.includes(node.type.name)) return; + if ((node.attrs.tabStops ?? null) === value) return; + tr.setNodeMarkup(pos, undefined, { ...node.attrs, tabStops: value }); + changed = true; + }); + if (changed && dispatch) dispatch(tr); + return changed; + }, + }; + }, + + addProseMirrorPlugins() { + let rafId: number | null = null; + let key = ''; + // Each pass measures the layout the previous one produced, so a tab whose x moved + // needs one more pass to settle (a stop can also rewrap the line). Bounded against + // a two-layout ping-pong; reset per external change. + let passes = 0; + const MAX_PASSES = 6; + // Layout changes (margins, orientation, zoom, styles) arrive as FORCE_PAGE_RECALC; + // counted here because a transaction is gone by the time `update` runs. Our own + // dispatch returns above the counter, so it can't re-trigger itself. + let forced = 0; + let seenForced = 0; + + return [ + new Plugin({ + key: tabStopsKey, + state: { + init: () => DecorationSet.empty, + apply(tr, old) { + const next = tr.getMeta(tabStopsKey) as DecorationSet | undefined; + if (next) return next; + if (tr.getMeta(FORCE_PAGE_RECALC)) forced++; + return old.map(tr.mapping, tr.doc); + }, + }, + props: { + decorations(state) { + return tabStopsKey.getState(state); + }, + }, + view(view) { + const calculate = () => { + rafId = null; + let widths: TabWidth[] = []; + // coordsAtPos throws on a position the browser hasn't rendered yet; the + // next change re-runs the pass anyway. + try { widths = measure(view); } catch { return; } + const next = widths.map((w) => `${w.pos}:${w.width}`).join(','); + if (next === key) return; + key = next; + const decos = widths.map((w) => + Decoration.inline(w.pos, w.pos + 1, { style: `tab-size:0;margin-right:${w.width}px` }), + ); + // The advances change line breaking, so pagination has to re-measure. + view.dispatch(view.state.tr + .setMeta(tabStopsKey, DecorationSet.create(view.state.doc, decos)) + .setMeta(FORCE_PAGE_RECALC, true) + .setMeta('addToHistory', false)); + if (passes < MAX_PASSES) { passes++; schedule(); } + }; + + const schedule = () => { + if (rafId !== null) cancelAnimationFrame(rafId); + rafId = requestAnimationFrame(calculate); + }; + + schedule(); + + return { + update(_v, prev) { + if (prev.doc === view.state.doc && forced === seenForced) return; + seenForced = forced; + passes = 0; + schedule(); + }, + destroy() { + if (rafId !== null) cancelAnimationFrame(rafId); + }, + }; + }, + }), + ]; + }, +}); diff --git a/src/lib/export/docx.ts b/src/lib/export/docx.ts index 0ef1a66..b39502d 100644 --- a/src/lib/export/docx.ts +++ b/src/lib/export/docx.ts @@ -5,7 +5,7 @@ import { AlignmentType, LevelFormat, UnderlineType, BorderStyle, ShadingType, WidthType, HeightRule, PageOrientation, LineRuleType, TableLayoutType, SectionType, HorizontalPositionAlign, VerticalPositionRelativeFrom, HorizontalPositionRelativeFrom, - TextWrappingType, TextWrappingSide, ImportedXmlComponent, + TextWrappingType, TextWrappingSide, ImportedXmlComponent, TabStopType, } from 'docx'; import type { TiptapNode } from 'odf-kit'; import type { @@ -20,6 +20,7 @@ import { pageDimsCm, type PageFormat } from '../storage/pageFormat'; import { HF_DISTANCE_CM, hfIsEmpty } from '../storage/headerFooter'; import { HEADER_SHADE } from '../editor/extensions/tableHeaderRow'; import { parseBorderAttr, type BorderSide } from '../editor/extensions/tableCellBorders'; +import { parseTabStops, type TabAlign } from '../editor/extensions/tabStops'; import { effectiveOrderedDefAt, formatOrdinal, childCycle, ROOT_ORDERED_CYCLE, type OrderedCycle } from '../utils/orderedListTypes'; import { defaultBulletChar } from '../utils/bulletListTypes'; import { normalizeColor, MAX_HEADING_LEVEL, mergeJoinedParagraphsJson, twinFontName, type HfExport } from './odt'; @@ -69,6 +70,11 @@ const TWIPS_PER_CM = 1440 / 2.54; // 566.929 const cmToTwip = (cm: number) => Math.round(cm * TWIPS_PER_CM); const ptToTwip = (pt: number) => Math.round(pt * 20); const pxToTwip = (px: number) => Math.round((px * 1440) / 96); // px @96dpi → twips + +const DOCX_TAB_TYPE: Record = { + left: TabStopType.LEFT, center: TabStopType.CENTER, + right: TabStopType.RIGHT, decimal: TabStopType.DECIMAL, +}; const EMU_PER_PX = 9525; const EMU_PER_PT = 12700; const EMU_PER_CM = 360000; @@ -615,6 +621,9 @@ function paragraphToDocx(node: TiptapNode, opts: ParaOpts = {}): Paragraph { } // The block's named style (a heading style id is what HeadingLevel references anyway). const style = docxStyleId(styleOf(node)); + // w:tabs. Word measures w:pos from the left text margin, the same origin the attr + // uses, so the position goes out unshifted by the paragraph's own indent. + const stops = parseTabStops(attrs.tabStops); // Paragraph-mark run props carry an empty line's font size (see import/docx.ts). const markSize = typeof attrs.fontSize === 'string' ? fontSizeToHalfPoints(attrs.fontSize) : undefined; return new Paragraph({ @@ -624,6 +633,9 @@ function paragraphToDocx(node: TiptapNode, opts: ParaOpts = {}): Paragraph { indent: indent.left != null || indent.hanging != null || indent.firstLine != null ? indent : undefined, pageBreakBefore: attrs.breakBefore === 'page' || undefined, widowControl: attrs.widowControl === false ? false : undefined, + tabStops: stops.length + ? stops.map((s) => ({ type: DOCX_TAB_TYPE[s.align], position: cmToTwip(s.pos) })) + : undefined, numbering: opts.numbering, shading: paraShadingOf(attrs), border: paraBordersOf(attrs), diff --git a/src/lib/export/odt.ts b/src/lib/export/odt.ts index d8f2ae8..f938d48 100644 --- a/src/lib/export/odt.ts +++ b/src/lib/export/odt.ts @@ -15,6 +15,7 @@ type TableStyleRef = { name: string; look: TableLook }; import { HEADER_SHADE } from '../editor/extensions/tableHeaderRow'; import { BORDER_SIDES, parseBorderAttr } from '../editor/extensions/tableCellBorders'; import { TEXTBOX_PADDING_CM } from '../editor/extensions/textBox'; +import { parseTabStops } from '../editor/extensions/tabStops'; import { orderedTypeDef, effectiveOrderedDef, effectiveOrderedDefAt, childCycle, ROOT_ORDERED_CYCLE, type OrderedCycle } from '../utils/orderedListTypes'; import { DEFAULT_BULLET_CYCLE, defaultBulletChar } from '../utils/bulletListTypes'; import { findFormat, renderFormat, odfNumberStyle, toDateValue, toTimeValue, localeTag, DEFAULT_DATE_FORMAT, DEFAULT_TIME_FORMAT, type DtFormat } from '../utils/dateTime'; @@ -224,6 +225,7 @@ function hasCustomAttrs(attrs: TiptapNode['attrs']): boolean { if (typeof attrs.fontSize === 'string' && attrs.fontSize) return true; if (typeof attrs.indent === 'number' && attrs.indent > 0) return true; if (typeof attrs.indentFirst === 'number' && attrs.indentFirst !== 0) return true; + if (typeof attrs.tabStops === 'string' && attrs.tabStops) return true; if (typeof attrs.backgroundColor === 'string' && attrs.backgroundColor) return true; if (attrs.widowControl === false) return true; for (const s of ['borderTop', 'borderRight', 'borderBottom', 'borderLeft']) @@ -2292,15 +2294,19 @@ function collapseRunWhitespace(odtBytes: Uint8Array): Uint8Array { // Rewrite the inline sentinels planted before serialization into real ODF elements: // LBR (from replaceHardBreaks) → , TAB (from replaceTabs) → // . Both are valid as bare paragraph text and inside . +// Also completes a decimal tab stop, which odf-kit can only write half of. +const CHAR_TAB_STOP = 'style:type="char"'; + function applyInlineSentinels(odtBytes: Uint8Array): Uint8Array { const files = unzipSync(odtBytes); const contentBytes = files['content.xml']; if (!contentBytes) return odtBytes; let content = strFromU8(contentBytes); - if (!content.includes(LBR) && !content.includes(TAB)) return odtBytes; + if (!content.includes(LBR) && !content.includes(TAB) && !content.includes(CHAR_TAB_STOP)) return odtBytes; content = content.split(LBR).join('').split(TAB).join(''); + content = content.split(CHAR_TAB_STOP).join(`${CHAR_TAB_STOP} style:char="."`); files['content.xml'] = strToU8(content); return rezipOdt(files); } @@ -2803,6 +2809,7 @@ export async function buildOdt(docJson: TiptapNode, margins: PageMargins = DEFAU spaceAfter?: string; indentLeft?: string; indentFirst?: string; + tabStops?: { position: string; type: 'left' | 'center' | 'right' }[]; } = {}; if (node.attrs?.lineHeight != null) { const lhRaw = String(node.attrs.lineHeight); @@ -2825,6 +2832,16 @@ export async function buildOdt(docJson: TiptapNode, margins: PageMargins = DEFAU if (typeof node.attrs?.indentFirst === 'number' && node.attrs.indentFirst !== 0) { opts.indentFirst = `${node.attrs.indentFirst}cm`; } + // Tab stops → . odf-kit's type has no decimal stop, so it goes + // out as 'char' and applyInlineSentinels adds the style:char ODF requires. + const stops = parseTabStops(node.attrs?.tabStops); + if (stops.length) { + opts.tabStops = stops.map((s) => ({ + position: `${s.pos}cm`, + // odf-kit writes style:type verbatim but types it without ODF's 'char'. + type: (s.align === 'decimal' ? 'char' : s.align) as 'left' | 'center' | 'right', + })); + } const content = node.content ?? []; // An empty line's font size (its paragraph-mark size) rides as an FSZ sentinel // that applyEmptyLineFontSizes turns into a paragraph-style fo:font-size. diff --git a/src/lib/import/docx.ts b/src/lib/import/docx.ts index 38d0b1d..19013db 100644 --- a/src/lib/import/docx.ts +++ b/src/lib/import/docx.ts @@ -1,9 +1,10 @@ import { unzipSync, strFromU8 } from 'fflate'; -import { DocxStyles, parseRunProps, mergeRunProps, readNumPr, toggle as onOff, wVal, W, R, WP, A, WPS, MC, VML, PKG_REL, type RunProps, type ParaSpacing } from './docxStyles'; +import { DocxStyles, parseRunProps, mergeRunProps, readNumPr, readTabStops, toggle as onOff, wVal, W, R, WP, A, WPS, MC, VML, PKG_REL, type RunProps, type ParaSpacing } from './docxStyles'; import { lengthToPt } from './styleResolver'; import { HEADING_STYLE_OVERRIDES, MAX_HEADING_LEVEL, normalizeColor } from '../export/odt'; import { builtinStyleSheet, DEFAULT_STYLE, type ParaProps, type Style, type StyleSheet, type TextProps } from '../styles/styleSheet'; import { HEADER_SHADE } from '../editor/extensions/tableHeaderRow'; +import { formatTabStops } from '../editor/extensions/tabStops'; import { tableLookAttr } from '../styles/tableStyles'; import { orderedTypeFromFormat, orderedTypeAttrAt, childCycle, ROOT_ORDERED_CYCLE, type OrderedCycle } from '../utils/orderedListTypes'; import { bulletCharAttr, bulletCharFromDocx } from '../utils/bulletListTypes'; @@ -683,6 +684,10 @@ function convertParagraph(el: Element, ctx: Ctx, kind: BlockKind, boldByDefault: if (!(directWc ? onOff(directWc) : ctx.styles.paragraphWidowControl(styleId))) { attrs.widowControl = false; } + // Tab stops: a direct w:tabs replaces the style's, which the resolver walks for. + const directTabs = fc(ppr, 'tabs'); + const stops = formatTabStops(directTabs ? readTabStops(directTabs) : ctx.styles.paragraphTabs(styleId)); + if (stops) attrs.tabStops = stops; const baseRun = ctx.styles.paragraphRun(styleId); const defaults = blockDefaults(baseRun, level, boldByDefault); const content = convertInline(el, ctx, baseRun, defaults, false); diff --git a/src/lib/import/docxStyles.ts b/src/lib/import/docxStyles.ts index 715f605..be2d839 100644 --- a/src/lib/import/docxStyles.ts +++ b/src/lib/import/docxStyles.ts @@ -1,3 +1,5 @@ +import type { TabAlign, TabStop } from '../editor/extensions/tabStops'; + // Resolves OOXML style indirection for the DOCX importer: Word/LibreOffice spread run // formatting across w:docDefaults and named styles linked by w:basedOn, and store list // numbering in numbering.xml. This flattens both so import/docx.ts reads effective values. @@ -39,6 +41,23 @@ export function wVal(el: Element): string | null { return el.getAttributeNS(W, 'val'); } +// → stops in cm from the left text margin, w:pos's origin (which the editor's +// attr shares). 'clear' removes an inherited stop and 'bar' is a rule, not a stop. +export function readTabStops(tabs: Element): TabStop[] { + const out: TabStop[] = []; + for (const tab of Array.from(tabs.getElementsByTagNameNS(W, 'tab'))) { + const val = wVal(tab); + if (val === 'clear' || val === 'bar') continue; + const pos = parseInt(tab.getAttributeNS(W, 'pos') ?? '', 10); + if (!Number.isFinite(pos)) continue; + const align: TabAlign = val === 'center' ? 'center' + : val === 'right' || val === 'end' ? 'right' + : val === 'decimal' ? 'decimal' : 'left'; + out.push({ pos: Math.round((pos / 1440) * 2.54 * 100) / 100, align }); + } + return out; +} + // A toggle property (w:b, w:i, …): present with no/true val = on; val false/0/off = off. export function toggle(el: Element): boolean { const v = wVal(el); @@ -105,6 +124,7 @@ export class DocxStyles { private defaultsAlign: string | null = null; // docDefaults w:pPrDefault/w:jc private defaultsSpacing: ParaSpacing = {}; // docDefaults w:pPrDefault/w:spacing private ownWidow = new Map(); // style's own w:pPr/w:widowControl + private ownTabs = new Map(); // style's own w:pPr/w:tabs private defaultsWidow: boolean | null = null; // docDefaults w:pPrDefault/w:widowControl private numToAbstract = new Map(); private abstractLevels = new Map>(); @@ -166,6 +186,8 @@ export class DocxStyles { if (sp) this.ownSpacing.set(id, readSpacing(sp)); const wc = ppr && firstChild(ppr, 'widowControl'); if (wc) this.ownWidow.set(id, toggle(wc)); + const tabs = ppr && firstChild(ppr, 'tabs'); + if (tabs) this.ownTabs.set(id, readTabStops(tabs)); const ind = ppr && firstChild(ppr, 'ind'); if (ind) { const left = parseInt(ind.getAttributeNS(W, 'left') ?? ind.getAttributeNS(W, 'start') ?? '', 10); @@ -299,6 +321,17 @@ export class DocxStyles { return this.defaultsWidow ?? true; } + // w:tabs along the w:basedOn chain (direct w:pPr/w:tabs is read and wins in the + // caller). The nearest declaration is taken whole — Word merges a style's stops with + // the paragraph's, which no document here has needed. + paragraphTabs(styleId: string | null | undefined, seen = new Set()): TabStop[] { + if (!styleId || seen.has(styleId)) return []; + seen.add(styleId); + const own = this.ownTabs.get(styleId); + if (own) return own; + return this.paragraphTabs(this.basedOn.get(styleId) ?? null, seen); + } + private styleWidow(styleId: string | null | undefined, seen = new Set()): boolean | null { if (!styleId || seen.has(styleId)) return null; seen.add(styleId); diff --git a/src/lib/import/odt.ts b/src/lib/import/odt.ts index a65c9a9..2260c04 100644 --- a/src/lib/import/odt.ts +++ b/src/lib/import/odt.ts @@ -3,6 +3,7 @@ import { StyleResolver, NS, lengthToPt, lengthToCm, layerTextProps, type PropMap 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 { HEADER_SHADE } from '../editor/extensions/tableHeaderRow'; +import { formatTabStops } from '../editor/extensions/tabStops'; import { TABLE_REGIONS, tableLookAttr, type TableLook, type TableRegion } from '../styles/tableStyles'; import { orderedTypeFromFormat, orderedTypeAttrAt, childCycle, ROOT_ORDERED_CYCLE, type OrderedCycle } from '../utils/orderedListTypes'; import { bulletCharAttr, bulletCharFromOdf } from '../utils/bulletListTypes'; @@ -863,6 +864,10 @@ function convertParaLike(el: Element, ctx: Ctx, kind: BlockKind, boldByDefault = const defaults = blockDefaults(resolver, named, isHeading ? level : null, boldByDefault); const attrs = blockAttrs(paraProps, baseTextProps, defaults, kind); + // Tab stops live in a child element of the paragraph properties, so they come from + // the resolver's own walk rather than the flattened paraProps. + const stops = formatTabStops(resolver.tabStops(styleName)); + if (stops) attrs.tabStops = stops; const content = convertInline(el, ctx, baseTextProps, defaults, false); // An empty line has no runs, so its height comes from the paragraph style's own diff --git a/src/lib/import/styleResolver.ts b/src/lib/import/styleResolver.ts index 23baf33..1b5d90b 100644 --- a/src/lib/import/styleResolver.ts +++ b/src/lib/import/styleResolver.ts @@ -1,6 +1,7 @@ import type { PageMargins } from '../storage/pageMargins'; import type { Orientation } from '../storage/pageOrientation'; import { formatFromCm, type PageFormat } from '../storage/pageFormat'; +import type { TabAlign } from '../editor/extensions/tabStops'; // Resolves ODF style indirection for the importer: producers (our export, LibreOffice, // Word) spread formatting across named/automatic styles and parent-style-name chains. @@ -135,6 +136,8 @@ export class StyleResolver { // Section styles kept as raw elements: is a child element of // , which the attribute-only StyleEntry drops. private sectionStyleEls = new Map(); + // Paragraph styles likewise, for the child element . + private paraStyleEls = new Map(); private mergedCache = new Map(); private stylesDoc: Document | null; private namedParagraphNames = new Set(); @@ -234,6 +237,7 @@ export class StyleResolver { if (display) this.displayNames.set(name, display); } if (name && family === 'section') this.sectionStyleEls.set(name, el); + if (name && family === 'paragraph') this.paraStyleEls.set(name, el); } else if (el.namespaceURI === NS.style && el.localName === 'default-style') { const family = el.getAttributeNS(NS.style, 'family'); if (family) this.defaults.set(family, entryFromStyleElement(el)); @@ -376,6 +380,33 @@ export class StyleResolver { return name ? this.numberStyles.get(name) ?? null : null; } + // Tab stops of a paragraph style, in cm from the left text margin (the origin Word + // and ODF share). ODF replaces the list rather than merging it, so the nearest + // declaration in the parent chain wins outright. + tabStops(styleName: string | null): { pos: number; align: TabAlign }[] { + const seen = new Set(); + let cur = styleName; + while (cur && !seen.has(cur)) { + seen.add(cur); + const el = this.paraStyleEls.get(cur); + const list = el?.getElementsByTagNameNS(NS.style, 'tab-stops')[0]; + if (list) { + const out: { pos: number; align: TabAlign }[] = []; + for (const stop of Array.from(list.getElementsByTagNameNS(NS.style, 'tab-stop'))) { + const pos = lengthToCm(stop.getAttributeNS(NS.style, 'position')); + if (pos == null) continue; + const type = stop.getAttributeNS(NS.style, 'type'); + const align: TabAlign = type === 'char' ? 'decimal' + : type === 'center' || type === 'right' ? type : 'left'; + out.push({ pos, align }); + } + return out; + } + cur = this.styles.get(`paragraph\0${cur}`)?.parent ?? null; + } + return []; + } + // A section style's column layout: { count, gapCm } when it declares more than one // column, else null. sectionColumns(name: string | null): { count: number; gapCm: number } | null { diff --git a/tests/unit/tab-stops.test.ts b/tests/unit/tab-stops.test.ts new file mode 100644 index 0000000..13db42a --- /dev/null +++ b/tests/unit/tab-stops.test.ts @@ -0,0 +1,66 @@ +// Tab stops: positions are cm from the left text margin in both formats (probe: a Word +// stop at 100mm in a 30mm-indented paragraph stays 100mm in ODF and renders there), so +// neither side shifts them by the paragraph indent. +import { describe, it, expect } from 'vitest'; +import { unzipSync, strFromU8 } from 'fflate'; +import { buildOdt } from '../../src/lib/export/odt'; +import { buildDocx } from '../../src/lib/export/docx'; +import { importOdt } from '../../src/lib/import/odt'; +import { importDocx } from '../../src/lib/import/docx'; +import { parseTabStops, formatTabStops } from '../../src/lib/editor/extensions/tabStops'; + +type N = any; + +const P = (text: string, attrs?: N): N => + ({ type: 'paragraph', ...(attrs ? { attrs } : {}), content: [{ type: 'text', text }] }); + +const doc: N = { + type: 'doc', + content: [ + P('plain'), + P('left\tcentred\tright\t12.34', { tabStops: '6c;12r;16d' }), + P('indented\tright', { indent: 3, tabStops: '10r' }), + ], +}; + +const margins = { top: 2, bottom: 2, left: 2, right: 2 }; +const stopsOf = (d: N, i: number) => d.content[i].attrs?.tabStops ?? null; + +describe('tab stops', () => { + it('parses and formats the canonical attr form', () => { + expect(parseTabStops('6c;12r;16d')).toEqual([ + { pos: 6, align: 'center' }, { pos: 12, align: 'right' }, { pos: 16, align: 'decimal' }, + ]); + // Sorted, deduped by position, rounded to 0.01cm; empty becomes null. + expect(formatTabStops([{ pos: 12, align: 'right' }, { pos: 1.004, align: 'left' }])).toBe('1l;12r'); + expect(formatTabStops([])).toBe(null); + expect(parseTabStops(null)).toEqual([]); + }); + + it('round-trips through ODF', async () => { + const bytes = await buildOdt(doc, margins, 'portrait'); + const xml = strFromU8(unzipSync(bytes)['content.xml']); + expect(xml).toMatch(//); + // ODF's decimal stop is style:type="char" plus the separator itself. + expect(xml).toMatch(/style:position="16cm" style:type="char" style:char="\."/); + + const back = importOdt(bytes).content as N; + expect(stopsOf(back, 0)).toBe(null); + expect(stopsOf(back, 1)).toBe('6c;12r;16d'); + expect(stopsOf(back, 2)).toBe('10r'); + }); + + it('round-trips through DOCX', async () => { + const bytes = await buildDocx(doc, margins, 'portrait'); + const xml = strFromU8(unzipSync(bytes)['word/document.xml']); + expect(xml).toMatch(//); + expect(xml).toMatch(//); + // The indented paragraph's stop keeps its margin-relative position. + expect(xml).toMatch(//); + + const back = importDocx(bytes).content as N; + expect(stopsOf(back, 0)).toBe(null); + expect(stopsOf(back, 1)).toBe('6c;12r;16d'); + expect(stopsOf(back, 2)).toBe('10r'); + }); +});