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:
1 parent
7d4a0b256e
commit
92d641f72f
9 files changed
+451
-3
No files matched your search
@@ -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,
|
||||
|
||||
@@ -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
@@ -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
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user