fix: suppress the base direction's own edge when reading w:jc

The DOCX import dropped left as the default and kept right regardless
of w:bidi; an rtl paragraph's default edge is right. start/end read as
physical left/right — LibreOffice spells an rtl paragraph's physical
left as w:jc="start" (probed via its own DOCX export).
This commit is contained in:
Steffen Becker committed 2026-08-30 10:06:43 +00:00
1 parent 27a8f4a11e
commit 104ec2366c
2 files changed
+43 -7

No files matched your search

+15 -7
View File
@@ -1005,11 +1005,15 @@ function convertParagraph(el: Element, ctx: Ctx, kind: BlockKind, boldByDefault:
const directJc = fc(ppr, 'jc');
const jcVal = directJc ? wVal(directJc) : ctx.styles.paragraphAlign(pStyle ? wVal(pStyle) : null);
const styleId = styleIdOf(ppr, ctx);
// w:bidi — the block's own base direction; resolved first, alignment suppression
// depends on it (the base direction decides which edge is the unset default).
const directBidi = fc(ppr, 'bidi');
const bidi = directBidi ? onOff(directBidi) : ctx.styles.paragraphBidi(styleId);
// Only DIRECT w:pPr counts as formatting on the block; the style's own lives in the
// registry — except in a cell, which carries no style name, so its chain is baked in
// over the table style's w:pPr (probed: that ranks *below* the paragraph style).
const attrs = blockAttrs(ppr, kind, level, directJc ? jcVal : null,
kind === 'cell' ? ctx.styles.paragraphSpacing(styleId, ctx.cellSpacing) : {});
kind === 'cell' ? ctx.styles.paragraphSpacing(styleId, ctx.cellSpacing) : {}, bidi ?? ctx.pageRtl);
applyContextualSpacing(el, ppr, ctx, styleId, attrs);
// The editor has no rule node, so Word's horizontal line becomes its paragraph's own
// bottom rule — a real w:pBdr keeps precedence, only one line can be drawn.
@@ -1038,10 +1042,8 @@ function convertParagraph(el: Element, ctx: Ctx, kind: BlockKind, boldByDefault:
// at all; below that switch it says what is already true.
const directSah = fc(ppr, 'suppressAutoHyphens');
if (ctx.hyphenate && directSah && onOff(directSah)) attrs.noHyphenation = true;
// w:bidi — the block's own base direction; the section's own is inheritance, not
// formatting, so only a block that differs from it carries the attr.
const directBidi = fc(ppr, 'bidi');
const bidi = directBidi ? onOff(directBidi) : ctx.styles.paragraphBidi(styleId);
// The section's own base direction is inheritance, not formatting, so only a block
// that differs from it carries the attr.
if (bidi != null && bidi !== ctx.pageRtl) attrs.dir = bidi ? 'rtl' : 'ltr';
// Tab stops: a direct w:tabs replaces the style's, which the resolver walks for.
const directTabs = fc(ppr, 'tabs');
@@ -1143,12 +1145,18 @@ function headingLevelOf(ppr: Element | null, ctx: Ctx): number | null {
// Spacing = the style chain's w:spacing (styleSpacing, resolved by the caller) overridden
// per-attribute by DIRECT w:pPr; indent comes from direct w:pPr only. jcVal is resolved
// through the chain by the caller.
function blockAttrs(ppr: Element | null, kind: BlockKind, headingLevel: number | null, jcVal: string | null, styleSpacing: ParaSpacing): Record<string, unknown> {
function blockAttrs(ppr: Element | null, kind: BlockKind, headingLevel: number | null, jcVal: string | null, styleSpacing: ParaSpacing, rtl = false): Record<string, unknown> {
const attrs: Record<string, unknown> = {};
if (jcVal === 'center') attrs.textAlign = 'center';
else if (jcVal === 'both' || jcVal === 'distribute') attrs.textAlign = 'justify';
else if (jcVal === 'right' || jcVal === 'end') attrs.textAlign = 'right';
else {
// start/end are physical like left/right: LibreOffice writes an rtl paragraph's
// physical left as w:jc="start" (probed). The direction's own edge stays unset.
const align = jcVal === 'right' || jcVal === 'end' ? 'right'
: jcVal === 'left' || jcVal === 'start' ? 'left' : null;
if (align && align !== (rtl ? 'right' : 'left')) attrs.textAlign = align;
}
const sp = ppr ? fc(ppr, 'spacing') : null;
const before = (sp ? intAttr(sp, W, 'before') : null) ?? styleSpacing.before ?? null;
+28
View File
@@ -63,3 +63,31 @@ describe('a right-to-left page', () => {
expect(importDocx(bytes).rtl).toBe(false);
});
});
// The alignment default follows the base direction: an unset rtl paragraph sits at the
// right edge, so that edge is suppressed on import and the off-edge kept.
describe('paragraph alignment under w:bidi', () => {
const P = (attrs: object) => ({ type: 'paragraph', attrs, content: [{ type: 'text', text: 'שלום' }] });
it('suppresses the rtl default edge, keeps the off-edge', async () => {
const doc: any = { type: 'doc', content: [
P({ dir: 'rtl', textAlign: 'right' }), P({ dir: 'rtl', textAlign: 'left' }), P({ textAlign: 'right' }),
] };
const back = (importDocx(await buildDocx(doc)).content as any).content;
expect(back[0].attrs?.textAlign ?? null).toBeNull();
expect(back[0].attrs?.dir).toBe('rtl');
expect(back[1].attrs?.textAlign).toBe('left');
expect(back[2].attrs?.textAlign).toBe('right');
});
// LibreOffice spells an rtl paragraph's physical left as w:jc="start" (probed).
it('reads w:jc="start" as the physical left edge', async () => {
const doc: any = { type: 'doc', content: [P({ dir: 'rtl', textAlign: 'left' })] };
const files = unzipSync(await buildDocx(doc));
files['word/document.xml'] = strToU8(strFromU8(files['word/document.xml'])
.replace('<w:jc w:val="left"/>', '<w:jc w:val="start"/>'));
const back = (importDocx(zipSync(files)).content as any).content;
expect(back[0].attrs?.textAlign).toBe('left');
expect(back[0].attrs?.dir).toBe('rtl');
});
});