feat: honour a paragraph's own tab stops

CSS only has the fixed tab-size grid, so a tab that resolves to one of the
paragraph's stops is measured and given the exact advance as an inline margin.
A hanging indent implies a stop at the text position, as in Word.
This commit is contained in:
Steffen Becker committed 2026-08-06 21:45:47 +00:00
1 parent 7d4a0b256e
commit 92d641f72f
9 files changed
+451 -3

No files matched your search

+2
View File
@@ -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,
+277
View File
@@ -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<TabAlign, string> = { left: 'l', center: 'c', right: 'r', decimal: 'd' };
const ALIGN: Record<string, TabAlign> = { l: 'left', c: 'center', r: 'right', d: 'decimal' };
// Canonical attr form: one '<cm><align code>' 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<number, TabAlign>();
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<ReturnType> {
tabStops: {
setTabStops: (stops: TabStop[]) => ReturnType;
};
}
}
const tabStopsKey = new PluginKey<DecorationSet>('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<string, unknown>) =>
(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<DecorationSet>({
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);
},
};
},
}),
];
},
});
+13 -1
View File
@@ -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<TabAlign, (typeof TabStopType)[keyof typeof TabStopType]> = {
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),
+18 -1
View File
@@ -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) → <text:line-break/>, TAB (from replaceTabs) →
// <text:tab/>. Both are valid as bare paragraph text and inside <text:span>.
// 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('<text:line-break/>').split(TAB).join('<text:tab/>');
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 → <style: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.
+6 -1
View File
@@ -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);
+33
View File
@@ -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');
}
// <w:tabs> → 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<string, boolean>(); // style's own w:pPr/w:widowControl
private ownTabs = new Map<string, TabStop[]>(); // style's own w:pPr/w:tabs
private defaultsWidow: boolean | null = null; // docDefaults w:pPrDefault/w:widowControl
private numToAbstract = new Map<string, string>();
private abstractLevels = new Map<string, Map<number, LevelDef>>();
@@ -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<string>()): 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<string>()): boolean | null {
if (!styleId || seen.has(styleId)) return null;
seen.add(styleId);
+5
View File
@@ -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
+31
View File
@@ -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: <style:columns> is a child element of
// <style:section-properties>, which the attribute-only StyleEntry drops.
private sectionStyleEls = new Map<string, Element>();
// Paragraph styles likewise, for the child element <style:tab-stops>.
private paraStyleEls = new Map<string, Element>();
private mergedCache = new Map<string, { text: PropMap; para: PropMap; misc: PropMap }>();
private stylesDoc: Document | null;
private namedParagraphNames = new Set<string>();
@@ -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<string>();
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 {
+66
View File
@@ -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(/<style:tab-stop style:position="6cm" style:type="center"\/>/);
// 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(/<w:tab w:val="center" w:pos="3402"\/>/);
expect(xml).toMatch(/<w:tab w:val="decimal" w:pos="9071"\/>/);
// The indented paragraph's stop keeps its margin-relative position.
expect(xml).toMatch(/<w:tab w:val="right" w:pos="5669"\/>/);
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');
});
});