fix(engine-render): improve traditional document layout fidelity (#7461)

This commit is contained in:
Univer
2026-08-10 04:15:50 +08:00
committed by GitHub
parent 7bc5fc4218
commit b930f76e64
34 changed files with 3476 additions and 169 deletions
+1
View File
@@ -41,6 +41,7 @@
"typecheck": "turbo typecheck",
"serve:umd": "serve .",
"test": "turbo test -- --passWithNoTests",
"test:docx-e2e": "pnpm --dir packages/engine-render exec vitest run src/components/docs/layout/__tests__/doc-skeleton.spec.ts src/components/docs/layout/__tests__/tools.spec.ts src/components/docs/layout/model/__tests__/page.spec.ts src/components/docs/layout/block/__tests__/table.spec.ts src/components/docs/layout/block/paragraph/__tests__/linebreaking.spec.ts -t \"DOCX golden e2e\"",
"coverage": "turbo --concurrency 50% coverage -- --passWithNoTests ",
"analyze:build": "node --experimental-strip-types ./scripts/build-analysis.mts",
"build": "pnpm run build:plugins && pnpm run build:presets",
@@ -125,6 +125,13 @@ export interface IDocStyles {
export interface IDocumentBody {
dataStream: string;
/**
* UTF-16 offsets of soft page-break tokens (`\f`) produced by the layout engine that last
* saved the source document. Renderers may honor them for traditional/paginated fidelity;
* exporters must keep them soft rather than converting them to authored page breaks.
*/
renderedPageBreaks?: number[];
textRuns?: ITextRun[]; // textRun styleinteraction
paragraphs?: IParagraph[]; // paragraph
@@ -215,7 +222,7 @@ export interface INestingLevel {
// <prefix>%[nestingLevel]<suffix>
glyphFormat: string; // https://developers.google.com/docs/api/reference/rest/v1/documents#nestinglevelms word lvlText
textStyle?: ITextStyle;
startNumber: number;
startNumber: number; // zero-based offset; 0 renders the first ordered item as 1
// Union field glyph_kind can be only one of the following:
glyphType?: ListGlyphType; // ordered list string is to support custom rules https://developers.google.com/docs/api/reference/rest/v1/documents#glyphtype ms numFmt: GlyphType
@@ -499,6 +506,10 @@ export interface IDocumentLayout {
defaultTabStop?: number; // 17.15.1.25 defaultTabStop (Distance Between Automatic Tab Stops) 0.5 in = 36ptthis value should be converted to the default font size when exporting
characterSpacingControl?: characterSpacingControlType; // characterSpacingControl 17.18.7 ST_CharacterSpacing (Character-Level Whitespace Compression Settings)default compressPunctuation
/** Use the legacy East Asian Word layout rules stored as OOXML `useFELayout`. */
useFELayout?: BooleanNumber;
/** Align automatic line height inside tables to the active document line grid. */
adjustLineHeightInTable?: BooleanNumber;
paragraphLineGapDefault?: number; // paragraphLineGapDefault default line spacing
spaceWidthEastAsian?: BooleanNumber; // add space between east asian and English
@@ -716,6 +727,12 @@ export interface IDocDrawingBase extends IDrawingParam {
layoutType: PositionedObjectLayoutType;
behindDoc?: BooleanNumber; // wrapNone
/** Keeps the anchor constrained to its containing table cell when enabled. */
layoutInCell?: BooleanNumber;
/** Allows this floating object to overlap other floating objects. */
allowOverlap?: BooleanNumber;
/** WordprocessingML stacking order for anchored objects. */
relativeHeight?: number;
start?: number[]; // wrapPolygon
lineTo?: number[][]; // wrapPolygon
wrapText?: WrapTextType; // wrapSquare | wrapThrough | wrapTight
@@ -903,6 +920,12 @@ export interface IParagraphProperties extends IIndentStart {
snapToGrid?: BooleanNumber; // snapToGrid 17.3.2.34 snapToGrid (Use Document Grid Settings For Inter-Character Spacing)
spaceAbove?: INumberUnit; // spaceAbove before beforeLines (Spacing Above Paragraph)
spaceBelow?: INumberUnit; // spaceBelow after afterLines (Spacing Below Paragraph)
/** Whether the layout engine should derive paragraph-before spacing from the active compatibility policy. */
beforeAutoSpacing?: BooleanNumber;
/** Whether the layout engine should derive paragraph-after spacing from the active compatibility policy. */
afterAutoSpacing?: BooleanNumber;
/** Suppresses spacing between consecutive paragraphs that share the same named style. */
contextualSpacing?: BooleanNumber;
borderBetween?: IParagraphBorder; // borderBetween
borderTop?: IParagraphBorder; // borderTop
borderBottom?: IParagraphBorder; // borderBottom
@@ -1030,6 +1053,7 @@ export enum DashStyleType {
export interface ITabStop {
offset: number; // offset
alignment: TabStopAlignment; // alignment
leader?: TabStopLeader; // leader drawn between the preceding text and this tab stop
}
/**
@@ -1042,6 +1066,16 @@ export enum TabStopAlignment {
END, // The tab stop is aligned to the end of the line.
}
export enum TabStopLeader {
TAB_STOP_LEADER_UNSPECIFIED,
NONE,
DOT,
HYPHEN,
UNDERSCORE,
HEAVY,
MIDDLE_DOT,
}
/**
* Properties of shading
*/
@@ -1188,6 +1222,10 @@ export interface ITableRow {
* corresponding `TABLE_ROW_START`/`TABLE_ROW_END` pair in `dataStream`.
*/
tableCells: ITableCell[]; // tableCells
/** Number of table-grid columns omitted before the first cell in this row. */
gridBefore?: number;
/** Number of table-grid columns omitted after the last cell in this row. */
gridAfter?: number;
// If omitted, then the table row shall automatically resize its height to the height required by its contents
// (the equivalent of an hRule value of auto)
trHeight: ITableRowSize; // 17.4.80 trHeight (Table Row Height)
@@ -259,6 +259,8 @@ export interface IStyleBase {
* fontFamily
*/
ff?: Nullable<string>;
/** Font family used for East Asian characters in rich text. */
eastAsiaFontFamily?: Nullable<string>;
/** Font size in points (pt), where 1 pt is 1/72 inch. */
fs?: number;
/**
@@ -356,6 +358,7 @@ export interface IStyleData extends IStyleBase {
*/
export const STYLE_KEYS = defineExactKeys<IStyleData>()([
'ff',
'eastAsiaFontFamily',
'fs',
'it',
'bl',
@@ -157,7 +157,7 @@ describe('UpdateDocumentSectionCommand', () => {
startIndex: 2,
endIndex: 2,
wholeEntity: true,
properties: { docxBreakType: 'column' },
properties: { breakType: 'column' },
}));
});
@@ -234,7 +234,7 @@ export const InsertDocumentColumnBreakCommand: ICommand<IInsertDocumentColumnBre
rangeId: `docx-break-${generateRandomId()}`,
rangeType: CustomRangeType.CUSTOM,
wholeEntity: true,
properties: { docxBreakType: DocxBreakType.COLUMN },
properties: { breakType: DocxBreakType.COLUMN },
}],
});
return executeSectionTextX(context.commandService, context.documentDataModel, textX, InsertDocumentColumnBreakCommand.id);
@@ -88,7 +88,7 @@ describe('FDocument in Node', () => {
expect(document?.save().body?.customRanges).toContainEqual(expect.objectContaining({
startIndex: 3,
endIndex: 3,
properties: { docxBreakType: 'column' },
properties: { breakType: 'column' },
}));
});
@@ -264,7 +264,7 @@ describe('FDocument', () => {
startIndex: 2,
endIndex: 2,
wholeEntity: true,
properties: { docxBreakType: 'column' },
properties: { breakType: 'column' },
}));
});
@@ -30,6 +30,7 @@ import type {
ITableRow,
ITextStyle,
PageOrientType,
TabStopLeader,
} from '@univerjs/core';
import type { BreakPointType } from '../components/docs/layout/line-breaker/break';
@@ -104,6 +105,10 @@ export interface IDocumentSkeletonPage {
height: number; // actual or content height, default 0
breakType: BreakType; // type of page break
/** Internal layout provenance used to distinguish a forced boundary from natural overflow. */
isExplicitPageBreak?: boolean;
/** Internal layout provenance for a page opened because content exhausted the previous page. */
isNaturalPageOverflow?: boolean;
st: number; // startIndex
ed: number; // endIndex
/** Whether this cell page is only a layout placeholder covered by a merged cell. */
@@ -140,6 +145,7 @@ export interface IDocumentSkeletonTable {
ed: number; // endIndex
tableId: string; // table id
tableSource: ITable;
hasPageBreak?: boolean;
parent?: IDocumentSkeletonPage;
}
@@ -269,6 +275,7 @@ export interface IDocumentSkeletonGlyph {
featureId?: string; // support interaction for feature ,eg. hyperLine person
drawingId?: string; // drawing.drawingId
fauxBoldStrokeWidth?: number;
tabLeader?: TabStopLeader;
}
export interface IDocumentSkeletonBullet {
@@ -329,6 +336,7 @@ export interface IDocumentSkeletonBoundingBox {
width: number; // width
ba: number; // boundingBoxAscent
bd: number; // boundingBoxDescent
normalLineHeight?: number; // Canvas font bounding-box height, used as the base for Word AUTO spacing
aba: number; // actualBoundingBoxAscent
abd: number; // actualBoundingBoxDescent
sp: number; // strikeoutPosition
@@ -129,6 +129,7 @@ export interface IParagraphTableCache {
export interface IParagraphConfig {
paragraphIndex: number;
isInsideTable?: boolean;
documentCompatibilityPolicy?: IDocumentCompatibilityPolicy;
useWordStyleLineHeight?: boolean;
usePptxFontSizeLineHeight?: boolean;
@@ -48,4 +48,43 @@ describe('document compatibility policy', () => {
expect(applyFontMetricCompatibility('5', fontStyle, bBox, traditional).width).toBeCloseTo(14.72);
expect(applyFontMetricCompatibility('5', fontStyle, bBox, modern).width).toBe(16);
});
it('scales only non-bold primary Arial Latin glyphs in traditional documents', () => {
const normalArial = {
...fontStyle,
fontString: 'normal normal 11.5pt Arial',
fontSize: 11.5,
originFontSize: 11.5,
fontFamily: 'Arial',
fontCache: 'normal normal 11.5pt Arial',
} as IDocumentSkeletonFontStyle;
const boldArial = {
...normalArial,
fontString: 'normal bold 11.5pt Arial',
fontCache: 'normal bold 11.5pt Arial',
} as IDocumentSkeletonFontStyle;
const browserMeasuredGlyph = { ...bBox, width: 10 };
const traditional = getDocumentCompatibilityPolicy(DocumentFlavor.TRADITIONAL);
const modern = getDocumentCompatibilityPolicy(DocumentFlavor.MODERN);
expect(applyFontMetricCompatibility('A', normalArial, browserMeasuredGlyph, traditional).width).toBeCloseTo(9.8);
expect(applyFontMetricCompatibility('A', boldArial, browserMeasuredGlyph, traditional).width).toBe(10);
expect(applyFontMetricCompatibility('A', normalArial, browserMeasuredGlyph, modern).width).toBe(10);
expect(applyFontMetricCompatibility('中', normalArial, browserMeasuredGlyph, traditional).width).toBe(10);
});
it('applies the traditional SimSun width policy when it is the East Asia fallback family', () => {
const simSunFallback = {
...fontStyle,
fontString: 'normal normal 12pt "Times New Roman", 宋体',
fontSize: 12,
originFontSize: 12,
fontFamily: '"Times New Roman", 宋体',
fontCache: 'normal normal 12pt "Times New Roman", 宋体',
} as IDocumentSkeletonFontStyle;
const traditional = getDocumentCompatibilityPolicy(DocumentFlavor.TRADITIONAL);
expect(applyFontMetricCompatibility('中', simSunFallback, { ...bBox, width: 10 }, traditional).width)
.toBeCloseTo(9.7);
});
});
@@ -32,7 +32,7 @@ import { Path, Rect } from '../../../shape';
import { Viewport } from '../../../viewport';
import { DocBackground } from '../doc-background';
import { DOCS_EXTENSION_TYPE } from '../doc-extension';
import { Documents, drawSectionColumnSeparators } from '../document';
import { Documents, drawSectionColumnSeparators, resolveHeaderFooterFieldGlyph } from '../document';
import { getDocumentCompatibilityPolicy } from '../document-compatibility';
import { setDocsTableRenderViewportProvider } from '../table-render-viewport';
@@ -313,6 +313,15 @@ function attachColumnGroup(page: any) {
}
describe('documents render', () => {
it('resolves PAGE and NUMPAGES fields without mutating the model glyph', () => {
const glyph = { st: 0, ed: 0, content: '1' } as any;
const pageRange = { startIndex: 0, endIndex: 0, properties: { fieldType: 'PAGE' } } as any;
const pageCountRange = { startIndex: 0, endIndex: 0, properties: { fieldType: 'NUMPAGES' } } as any;
expect(resolveHeaderFooterFieldGlyph(glyph, 0, 0, [pageRange], 15, 16).content).toBe('15');
expect(resolveHeaderFooterFieldGlyph(glyph, 0, 0, [pageCountRange], 15, 16).content).toBe('16');
expect(glyph.content).toBe('1');
});
let restoreEnv: () => void;
let container: HTMLDivElement;
let engine: Engine;
@@ -1301,16 +1310,17 @@ describe('documents render', () => {
documents.dispose();
});
it('does not clip DOCX tables that extend into margins while fitting the physical page', () => {
it('does not clip traditional tables with a model width that extend into margins', () => {
const bodyPage = createPage(DocumentSkeletonPageType.BODY, '');
attachTable(bodyPage);
const table = bodyPage.skeTables.get('table-1')!;
table.left = -6;
table.width = 190;
table.tableSource = {
docxWidth: {
value: '2850',
type: 'dxa',
size: {
width: {
v: 190,
},
},
};
table.rows[0].cells[0].pageWidth = 190;
@@ -63,6 +63,12 @@ const TRADITIONAL_DOCUMENT_COMPATIBILITY_POLICY: IDocumentCompatibilityPolicy =
useWordStyleLineHeight: true,
font: {
metricScaleRules: [
{
fontFamily: /^arial$/i,
fontString: /^\S+\s+normal\s+\d+(?:\.\d+)?pt\s+["']?Arial["']?(?:,|$)/i,
content: /^[\u0000-\u024F\u2000-\u206F]$/u,
widthScale: 0.98,
},
{
fontFamily: /^calibri$/i,
minFontSize: 20,
@@ -70,6 +76,11 @@ const TRADITIONAL_DOCUMENT_COMPATIBILITY_POLICY: IDocumentCompatibilityPolicy =
content: /^[\d/]+$/u,
widthScale: 0.92,
},
{
fontFamily: /^(?:宋体|SimSun)$/i,
content: /^[\u2E80-\u2FFF\u31C0-\u31EF\u3400-\u4DBF\u4E00-\u9FFF\uF900-\uFAFF]$/u,
widthScale: 0.97,
},
],
},
table: {
@@ -132,16 +143,18 @@ export function applyFontMetricCompatibility(
};
}
export function isTraditionalDocumentCompatibility(policy: IDocumentCompatibilityPolicy): boolean {
return policy.mode === 'traditional';
export function isTraditionalDocumentCompatibility(policy?: IDocumentCompatibilityPolicy): boolean {
return policy?.mode === 'traditional';
}
export function shouldAllowImportedTableMarginOverflow(
policy: IDocumentCompatibilityPolicy,
tableSource: ITable | unknown
): boolean {
return policy.table.allowImportedTableMarginOverflow &&
tableSource != null &&
typeof tableSource === 'object' &&
'docxWidth' in tableSource;
if (!policy.table.allowImportedTableMarginOverflow || tableSource == null || typeof tableSource !== 'object') {
return false;
}
const table = tableSource as Partial<ITable>;
return table.size?.width != null;
}
@@ -14,7 +14,7 @@
* limitations under the License.
*/
import type { DocumentFlavor, IDocumentRenderConfig, IScale, ITableCell, ITableCellBorder, Nullable } from '@univerjs/core';
import type { DocumentFlavor, ICustomRange, IDocumentRenderConfig, IScale, ITableCell, ITableCellBorder, Nullable } from '@univerjs/core';
import type {
IDocumentSkeletonColumnGroup,
IDocumentSkeletonColumnGroupColumn,
@@ -60,6 +60,29 @@ const DEFAULT_BORDER_COLOR: ITableCellBorder = {
const TABLE_VIEWPORT_BORDER_CLIP_PADDING = 2;
const TABLE_OVERFLOW_INTERACTION_PADDING = 24;
export function resolveHeaderFooterFieldGlyph(
glyph: IDocumentSkeletonGlyph,
startIndex: number,
endIndex: number,
customRanges: ICustomRange[],
pageNumber: number,
pageCount: number
): IDocumentSkeletonGlyph {
const fieldRange = customRanges.find((customRange) =>
customRange.startIndex <= startIndex &&
customRange.endIndex >= endIndex &&
typeof customRange.properties?.fieldType === 'string'
);
const fieldType = fieldRange?.properties?.fieldType?.toUpperCase();
const content = fieldType === 'PAGE'
? String(pageNumber)
: fieldType === 'NUMPAGES'
? String(pageCount)
: undefined;
return content == null || content === glyph.content ? glyph : { ...glyph, content };
}
export interface IPageRenderConfig {
page: IDocumentSkeletonPage;
pageLeft: number;
@@ -329,7 +352,8 @@ export class Documents extends DocComponent {
renderConfig,
parentScale,
page,
true
true,
pages.length
);
}
@@ -589,7 +613,8 @@ export class Documents extends DocComponent {
renderConfig,
parentScale,
page,
false
false,
pages.length
);
}
@@ -1313,13 +1338,22 @@ export class Documents extends DocComponent {
renderConfig: IDocumentRenderConfig,
parentScale: IScale,
parentPage: IDocumentSkeletonPage,
isHeader = true
isHeader = true,
pageCount = 1
) {
if (this._drawLiquid == null) {
return;
}
const { sections, skeTables } = page;
const { y: originY } = this._drawLiquid;
const skeleton = this.getSkeleton();
const customRanges = typeof skeleton?.getViewModel === 'function'
? skeleton
.getViewModel()
.getSelfOrHeaderFooterViewModel(page.segmentId)
.getBody()
?.customRanges ?? []
: [];
if (skeTables.size > 0) {
const tablePage = {
@@ -1397,6 +1431,7 @@ export class Documents extends DocComponent {
for (let i = 0; i < divideLength; i++) {
const divide = divides[i];
const { glyphGroup } = divide;
let glyphStartIndex = divide.st;
this._drawLiquid.translateSave();
this._drawLiquid.translateDivide(divide);
@@ -1414,11 +1449,20 @@ export class Documents extends DocComponent {
// Draw text\border\lines etc.
for (const glyph of glyphGroup) {
if (!glyph.content || glyph.content.length === 0) {
const renderGlyph = resolveHeaderFooterFieldGlyph(
glyph,
glyphStartIndex,
glyphStartIndex + glyph.count - 1,
customRanges,
parentPage.pageNumber,
pageCount
);
glyphStartIndex += glyph.count;
if (!renderGlyph.content || renderGlyph.content.length === 0) {
continue;
}
const { width: spanWidth, left: spanLeft, xOffset } = glyph;
const { width: spanWidth, left: spanLeft, xOffset } = renderGlyph;
const { x: translateX, y: translateY } = this._drawLiquid;
@@ -1456,7 +1500,7 @@ export class Documents extends DocComponent {
for (const extension of glyphExtensionsExcludeBackground) {
extension.extensionOffset = extensionOffset;
extension.draw(ctx, parentScale, glyph);
extension.draw(ctx, parentScale, renderGlyph);
}
}
@@ -14,7 +14,7 @@
* limitations under the License.
*/
import type { IDocTextFill, IDocTextFillGradientStop, IScale } from '@univerjs/core';
import type { IDocTextFill, IDocTextFillGradientStop, IScale, TabStopLeader } from '@univerjs/core';
import type { IBoundRectNoAngle } from '../../../basics';
import type { IDocumentSkeletonGlyph } from '../../../basics/i-document-skeleton-cached';
import type { UniverRenderingContext } from '../../../context';
@@ -402,6 +402,18 @@ export class FontAndBaseLine extends docExtension {
return;
}
if (glyph.glyphType === GlyphType.TAB && glyph.tabLeader != null) {
const leader = this._getTabLeaderCharacter(glyph.tabLeader);
if (leader) {
const leaderWidth = ctx.measureText(leader).width;
const count = leaderWidth > 0 ? Math.floor(width / leaderWidth) : 0;
if (count > 0) {
ctx.fillText(leader.repeat(count), spanPointWithFont.x, spanPointWithFont.y);
}
}
return;
}
const { vertexAngle, centerAngle } = renderConfig ?? {};
const VERTICAL_DEG = 90;
@@ -448,6 +460,23 @@ export class FontAndBaseLine extends docExtension {
}
}
private _getTabLeaderCharacter(leader: TabStopLeader): string {
switch (leader) {
case 2:
return '.';
case 3:
return '-';
case 4:
return '_';
case 5:
return '\u2022';
case 6:
return '\u00B7';
default:
return '';
}
}
override clearCache() {
this._preFontColor = '';
}
File diff suppressed because it is too large Load Diff
@@ -222,7 +222,7 @@ describe('docs layout tools extra', () => {
});
it('defaults word-style docx paragraphs to snap when the section uses a document grid', () => {
const lineCfg = getLineHeightConfig({
const linesAndCharsCfg = getLineHeightConfig({
linePitch: 30.46666666666667,
gridType: GridType.LINES_AND_CHARS,
} as any, {
@@ -233,7 +233,76 @@ describe('docs layout tools extra', () => {
},
} as any);
expect(lineCfg.snapToGrid).toBe(BooleanNumber.TRUE);
const linesCfg = getLineHeightConfig({
linePitch: 30.46666666666667,
gridType: GridType.LINES,
} as any, {
useWordStyleLineHeight: true,
paragraphStyle: {},
} as any);
const charsOnlyCfg = getLineHeightConfig({
linePitch: 30.46666666666667,
gridType: GridType.SNAP_TO_CHARS,
} as any, {
useWordStyleLineHeight: true,
paragraphStyle: {},
} as any);
expect(linesAndCharsCfg.snapToGrid).toBe(BooleanNumber.TRUE);
expect(linesCfg.snapToGrid).toBe(BooleanNumber.TRUE);
expect(charsOnlyCfg.snapToGrid).toBe(BooleanNumber.FALSE);
});
it('applies the classic table-grid line-height policy only when enabled', () => {
const paragraphConfig = {
useWordStyleLineHeight: true,
isInsideTable: true,
paragraphStyle: {
lineSpacing: 1.5,
spacingRule: SpacingRule.AUTO,
},
};
const enabled = getLineHeightConfig({
linePitch: 20.8,
gridType: GridType.LINES,
adjustLineHeightInTable: BooleanNumber.TRUE,
} as any, paragraphConfig as any);
const disabled = getLineHeightConfig({
linePitch: 20.8,
gridType: GridType.LINES,
adjustLineHeightInTable: BooleanNumber.FALSE,
} as any, paragraphConfig as any);
const body = getLineHeightConfig({
linePitch: 20.8,
gridType: GridType.LINES,
adjustLineHeightInTable: BooleanNumber.FALSE,
} as any, { ...paragraphConfig, isInsideTable: false } as any);
expect({
tableWithCompatibility: enabled.snapToGrid,
tableWithoutCompatibility: disabled.snapToGrid,
bodyWithoutCompatibility: body.snapToGrid,
}).toMatchInlineSnapshot(`
{
"bodyWithoutCompatibility": 1,
"tableWithCompatibility": 1,
"tableWithoutCompatibility": 0,
}
`);
});
it('does not activate a line grid when a word-style section only carries line pitch', () => {
const lineCfg = getLineHeightConfig({
linePitch: 24,
} as any, {
useWordStyleLineHeight: true,
paragraphStyle: {},
} as any);
expect(lineCfg.gridType).toBe(GridType.DEFAULT);
expect(lineCfg.snapToGrid).toBe(BooleanNumber.FALSE);
expect(lineCfg.lineSpacing).toBe(1);
});
it('updates block index values and iterates skeleton blocks', () => {
@@ -400,7 +469,7 @@ describe('docs layout tools extra', () => {
expect(fromLastGlyph.charSpace).toBe(1);
const viewModel = {
getTextRun: vi.fn(() => ({ st: 0, ed: 10, ts: { fs: 12, ff: 'Arial' } })),
getTextRun: vi.fn(() => ({ st: 0, ed: 10, ts: { fs: 12, ff: 'Arial', eastAsiaFontFamily: '宋体' } })),
getCustomDecoration: vi.fn(() => null),
getCustomRange: vi.fn(() => null),
getDataModel: vi.fn(() => ({
@@ -425,6 +494,7 @@ describe('docs layout tools extra', () => {
const config1 = getFontCreateConfig(0, viewModel as any, paragraphNode as any, sectionBreakConfig as any, paragraph as any);
const config2 = getFontCreateConfig(0, viewModel as any, paragraphNode as any, sectionBreakConfig as any, paragraph as any);
expect(config1).toBe(config2);
expect(config1.fontStyle.fontFamily).toBe('Arial, 宋体');
const configWithBullet = getFontCreateConfig(
0,
@@ -538,7 +608,7 @@ describe('docs layout tools extra', () => {
expect(getPageFromPath(root as any, ['skeTables', 't1', 'rows', 0, 'cells', 0])).toBeNull();
});
it('inherits header and footer references from the previous traditional section', () => {
it('DOCX golden e2e inherits header and footer references from the previous traditional section', () => {
const sections = [
{ sectionId: 'section_1', defaultHeaderId: 'header-section-1' },
{ sectionId: 'section_2' },
@@ -560,6 +630,49 @@ describe('docs layout tools extra', () => {
expect(prepareSectionBreakConfig(ctx as any, 1).headerIds?.defaultHeaderId).toBe('header-section-1');
});
it('DOCX golden e2e does not inherit the title-page flag into a traditional section', () => {
const sections = [
{ sectionId: 'cover', useFirstPageHeaderFooter: BooleanNumber.TRUE },
{ sectionId: 'body' },
];
const ctx = {
docsConfig: {},
viewModel: {
getChildren: () => [{ endIndex: 4 }, { endIndex: 9 }],
getSectionBreak: (endIndex: number) => endIndex === 4 ? sections[0] : sections[1],
},
dataModel: {
documentStyle: {
documentFlavor: DocumentFlavor.TRADITIONAL,
useFirstPageHeaderFooter: BooleanNumber.TRUE,
},
},
};
expect(prepareSectionBreakConfig(ctx as any, 0).useFirstPageHeaderFooter).toBe(BooleanNumber.TRUE);
expect(prepareSectionBreakConfig(ctx as any, 1).useFirstPageHeaderFooter).toBe(BooleanNumber.FALSE);
});
it('keeps a traditional section without an explicit grid off the line grid', () => {
const ctx = {
docsConfig: {},
viewModel: {
getChildren: () => [{ endIndex: 4 }],
getSectionBreak: () => ({ sectionId: 'section_1', linePitch: 24 }),
},
dataModel: {
documentStyle: {
documentFlavor: DocumentFlavor.TRADITIONAL,
linePitch: 24,
},
},
};
const sectionConfig = prepareSectionBreakConfig(ctx as any, 0);
expect(sectionConfig.linePitch).toBe(24);
expect(sectionConfig.gridType).toBe(GridType.DEFAULT);
});
it('iterates document skeleton lines with nested table and column layout context', () => {
const page = {
marginLeft: 60,
@@ -26,6 +26,7 @@ import {
VerticalAlignmentType,
} from '@univerjs/core';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { BreakType, DocumentSkeletonPageType } from '../../../../../basics/i-document-skeleton-cached';
import { getDocumentCompatibilityPolicy } from '../../../document-compatibility';
import {
createTableSkeleton,
@@ -328,6 +329,45 @@ describe('docs table layout', () => {
expect(skeleton?.rows[0].cells[1].marginTop).toBeGreaterThanOrEqual(1);
});
it('uses every nested cell page when estimating a table inside a cell', () => {
const { ctx, curPage, viewModel, tableNode, sectionBreakConfig, tableSource } = createContextAndTable();
curPage.type = DocumentSkeletonPageType.CELL;
tableSource.tableRows = [{
trHeight: { hRule: TableRowHeightRule.AUTO, val: { v: 0 } },
cantSplit: BooleanNumber.FALSE,
tableCells: [{ vAlign: VerticalAlignmentType.TOP }],
}];
tableNode.children = [createRowNode(1, 20, 1) as any];
createSkeletonCellPagesMock.mockReturnValue([
makeCellPage(60, 20),
makeCellPage(60, 30),
]);
const skeleton = createTableSkeleton(ctx, curPage, viewModel, tableNode, sectionBreakConfig);
expect(skeleton?.height).toBe(54);
expect(skeleton?.rows[0].height).toBe(54);
});
it('does not count top-level cell continuation pages before the table paginator runs', () => {
const { ctx, curPage, viewModel, tableNode, sectionBreakConfig, tableSource } = createContextAndTable();
tableSource.tableRows = [{
trHeight: { hRule: TableRowHeightRule.AUTO, val: { v: 0 } },
cantSplit: BooleanNumber.FALSE,
tableCells: [{ vAlign: VerticalAlignmentType.TOP }],
}];
tableNode.children = [createRowNode(1, 20, 1) as any];
createSkeletonCellPagesMock.mockReturnValue([
makeCellPage(60, 20),
makeCellPage(60, 30),
]);
const skeleton = createTableSkeleton(ctx, curPage, viewModel, tableNode, sectionBreakConfig);
expect(skeleton?.height).toBe(22);
expect(skeleton?.rows[0].height).toBe(22);
});
it('keeps covered merged cells as non-rendering layout placeholders', () => {
const { ctx, curPage, viewModel, tableNode, sectionBreakConfig, tableSource } = createContextAndTable();
createSkeletonCellPagesMock.mockImplementation(
@@ -392,10 +432,10 @@ describe('docs table layout', () => {
);
});
it('treats explicit row height as a minimum so wrapped cell content remains visible', () => {
it('treats at-least row height as a minimum so wrapped cell content remains visible', () => {
const { ctx, curPage, viewModel, tableNode, sectionBreakConfig, tableSource } = createContextAndTable();
tableSource.tableRows[0].trHeight = {
hRule: TableRowHeightRule.EXACT,
hRule: TableRowHeightRule.AT_LEAST,
val: { v: 18 },
};
createSkeletonCellPagesMock.mockImplementation(
@@ -474,7 +514,7 @@ describe('docs table layout', () => {
expect(result.fromCurrentPage).toBe(false);
});
it('lays out splittable auto rows against the remaining page height', () => {
it('DOCX golden e2e lays out splittable auto rows against the remaining page height', () => {
const { ctx, curPage, viewModel, tableNode, sectionBreakConfig, tableSource } = createContextAndTable();
tableSource.tableRows[0].repeatHeaderRow = BooleanNumber.FALSE;
tableSource.tableRows[0].trHeight = {
@@ -497,6 +537,103 @@ describe('docs table layout', () => {
expect(secondRowCall?.[7]).toBe(100);
});
it('top-aligns every continuation fragment of a vertically centered split row', () => {
const { ctx, curPage, viewModel, tableNode, sectionBreakConfig, tableSource } = createContextAndTable();
tableSource.tableRows[0].tableCells = [
{ vAlign: VerticalAlignmentType.CENTER },
{ vAlign: VerticalAlignmentType.CENTER },
];
tableSource.tableRows[0].cantSplit = BooleanNumber.FALSE;
tableNode.children = [createRowNode(1, 20, 2) as any];
createSkeletonCellPagesMock.mockImplementation(() => [makeCellPage(60, 20), makeCellPage(60, 20)]);
const result = createTableSkeletons(ctx, curPage, viewModel, tableNode, sectionBreakConfig, 90);
const fragments = result.skeTables.flatMap((table) => table.rows).filter((row) => row.index === 0);
expect(fragments).toHaveLength(2);
expect(fragments.flatMap((row) => row.cells).every((cell) => cell.marginTop === cell.originMarginTop)).toBe(true);
});
it('propagates an explicit cell page boundary through an ancestor table measured with infinite height', () => {
const { ctx, curPage, viewModel, tableNode, sectionBreakConfig, tableSource } = createContextAndTable();
tableSource.tableRows = [{
trHeight: { hRule: TableRowHeightRule.AUTO, val: { v: 0 } },
cantSplit: BooleanNumber.FALSE,
tableCells: [{ vAlign: VerticalAlignmentType.TOP }],
}];
tableNode.children = [createRowNode(1, 20, 1) as any];
createSkeletonCellPagesMock.mockReturnValue([
makeCellPage(60, 20),
{ ...makeCellPage(60, 20), breakType: BreakType.PAGE, isExplicitPageBreak: true },
]);
const result = createTableSkeletons(
ctx,
curPage,
viewModel,
tableNode,
sectionBreakConfig,
Number.POSITIVE_INFINITY
);
expect(result.skeTables).toHaveLength(2);
expect(result.skeTables.map((table) => table.rows.length)).toEqual([1, 1]);
});
it('does not propagate a natural cell continuation through an ancestor table measured with infinite height', () => {
const { ctx, curPage, viewModel, tableNode, sectionBreakConfig, tableSource } = createContextAndTable();
tableSource.tableRows = [{
trHeight: { hRule: TableRowHeightRule.AUTO, val: { v: 0 } },
cantSplit: BooleanNumber.FALSE,
tableCells: [{ vAlign: VerticalAlignmentType.TOP }],
}];
tableNode.children = [createRowNode(1, 20, 1) as any];
createSkeletonCellPagesMock.mockReturnValue([
makeCellPage(60, 20),
{ ...makeCellPage(60, 20), breakType: BreakType.PAGE, isNaturalPageOverflow: true },
]);
const result = createTableSkeletons(
ctx,
curPage,
viewModel,
tableNode,
sectionBreakConfig,
Number.POSITIVE_INFINITY
);
expect(result.skeTables).toHaveLength(1);
expect(result.skeTables[0].rows).toHaveLength(2);
});
it('DOCX golden e2e lays out splittable at-least rows against the remaining page height', () => {
const { ctx, curPage, viewModel, tableNode, sectionBreakConfig, tableSource } = createContextAndTable();
tableSource.tableRows[0].repeatHeaderRow = BooleanNumber.FALSE;
tableSource.tableRows[0].trHeight = {
hRule: TableRowHeightRule.AT_LEAST,
val: { v: 20 },
};
tableSource.tableRows[0].cantSplit = BooleanNumber.FALSE;
createTableSkeletons(ctx, curPage, viewModel, tableNode, sectionBreakConfig, 90);
const firstRowCall = createSkeletonCellPagesMock.mock.calls.find((call) => call[5] === 0 && call[6] === 0);
expect(firstRowCall?.[7]).toBe(90);
});
it('uses the declared height for exact rows even when content is taller', () => {
const { ctx, curPage, viewModel, tableNode, sectionBreakConfig, tableSource } = createContextAndTable();
tableSource.tableRows[0].trHeight = {
hRule: TableRowHeightRule.EXACT,
val: { v: 40 },
};
createSkeletonCellPagesMock.mockReturnValue([makeCellPage(60, 90)]);
const result = createTableSkeleton(ctx, curPage, viewModel, tableNode, sectionBreakConfig);
expect(result?.rows[0].height).toBe(40);
});
it('keeps a splittable auto row on the current page when its first slice slightly overflows', () => {
const { ctx, curPage, viewModel, tableNode, sectionBreakConfig, tableSource } = createContextAndTable();
useDocumentFlavor(sectionBreakConfig, DocumentFlavor.TRADITIONAL);
@@ -521,7 +658,7 @@ describe('docs table layout', () => {
expect(result.skeTables[1]?.rows.map((row) => row.index) ?? []).not.toContain(1);
});
it('repeats multiple leading header rows on sliced table pages', () => {
it('DOCX golden e2e repeats multiple leading header rows on sliced table pages', () => {
const { ctx, curPage, viewModel, tableNode, sectionBreakConfig, tableSource } = createContextAndTable();
tableSource.tableRows[1].repeatHeaderRow = BooleanNumber.TRUE;
tableSource.tableRows.push(
@@ -560,6 +697,32 @@ describe('docs table layout', () => {
expect(result.skeTables[1].rows[2]).toMatchObject({ index: 2, isRepeatRow: false });
});
it('DOCX golden e2e paginates an all-header table like the same table without repeat headers', () => {
const paginate = (repeatHeaderRow: BooleanNumber) => {
const { ctx, curPage, viewModel, tableNode, sectionBreakConfig, tableSource } = createContextAndTable();
const row = {
repeatHeaderRow,
trHeight: {
hRule: TableRowHeightRule.AT_LEAST,
val: { v: 60 },
},
cantSplit: BooleanNumber.TRUE,
tableCells: [
{ vAlign: VerticalAlignmentType.TOP },
{ vAlign: VerticalAlignmentType.TOP },
],
};
tableSource.tableRows = new Array(9).fill(null).map(() => ({ ...row }));
tableNode.children = new Array(9).fill(null).map((_, index) => createRowNode(index * 20 + 1, index * 20 + 20, 2));
return createTableSkeletons(ctx, curPage, viewModel, tableNode, sectionBreakConfig, 100).skeTables.map(
(table) => table.rows.filter((tableRow) => !tableRow.isRepeatRow).map((tableRow) => tableRow.index)
);
};
expect(paginate(BooleanNumber.TRUE)).toEqual(paginate(BooleanNumber.FALSE));
});
it('returns an empty slice result when the table is missing', () => {
const { ctx, curPage, tableNode, sectionBreakConfig } = createContextAndTable();
const noTableViewModel = {
@@ -14,7 +14,7 @@
* limitations under the License.
*/
import type { IDocumentSkeletonGlyph } from '../../../../../../basics/i-document-skeleton-cached';
import type { IDocumentSkeletonDivide, IDocumentSkeletonGlyph } from '../../../../../../basics/i-document-skeleton-cached';
import type { IParagraphConfig } from '../../../../../../basics/interfaces';
import {
BooleanNumber,
@@ -199,6 +199,26 @@ describe('layout-ruler', () => {
}
});
it('aligns following text to an explicit end tab stop', () => {
const tab = createGlyph(DataStreamTreeTokenType.TAB, 36);
tab.glyphType = GlyphType.TAB;
tab.left = 100;
const pageNumber = createGlyph('1', 8);
const paragraphMark = createGlyph(DataStreamTreeTokenType.PARAGRAPH, 8);
const divide = { glyphGroup: [tab], width: 580 } as IDocumentSkeletonDivide;
const paragraphConfig = {
paragraphStyle: {
tabStops: [{ offset: 600, alignment: 3, leader: 2 }],
},
} as IParagraphConfig;
__testing.adjustExplicitTabStop(divide, [pageNumber, paragraphMark], paragraphConfig);
expect(tab.width).toBe(464);
expect(tab.bBox.width).toBe(464);
expect(tab.tabLeader).toBe(2);
});
it('uses trailing CJK punctuation shrinkability when deciding line overflow', () => {
const text = createGlyph('字', 10);
const punctuation = createGlyph('', 10);
@@ -253,6 +273,7 @@ describe('layout-ruler', () => {
expect(paragraphConfig.paragraphStyle?.indentStart).toEqual({ v: 12 });
expect(paragraphConfig.paragraphStyle?.hanging).toEqual({ v: 12 });
expect(curPage.sections[0].columns[0].lines[0].divides[0].paddingLeft).toBe(0);
expect(curPage.sections[0].columns[0].lines[0].divides[0].glyphGroup[0].width).toBe(12);
});
@@ -548,18 +569,48 @@ describe('layout-ruler', () => {
expect(getLineBoxHeight(metrics)).toBeCloseTo(24, 4);
});
it('uses the font normal line height as the base for Word auto spacing', () => {
const metrics = getLineHeightMetrics(17, 0, 24, GridType.DEFAULT, 1.5, SpacingRule.AUTO, BooleanNumber.FALSE, true, true, 18.6666666667);
expect(getLineBoxHeight(metrics)).toBeCloseTo(28, 4);
});
it('does not multiply inline custom block height by auto line spacing', () => {
const metrics = getLineHeightMetrics(624, 0, 15.6, GridType.LINES, 1.5, SpacingRule.AUTO, BooleanNumber.FALSE, true, false);
expect(getLineBoxHeight(metrics)).toBeCloseTo(624, 4);
});
it('does not collapse an inline custom block to exact text line spacing', () => {
const metrics = getLineHeightMetrics(624, 0, 15.6, GridType.LINES, 20.8, SpacingRule.EXACT, BooleanNumber.FALSE, true, false);
expect(getLineBoxHeight(metrics)).toBeCloseTo(624, 4);
});
it('keeps document-grid line pitch behavior when auto line spacing explicitly snaps to the grid', () => {
const metrics = getLineHeightMetrics(16, 0, 15.6, GridType.LINES, 1.5, SpacingRule.AUTO, BooleanNumber.TRUE, true);
expect(getLineBoxHeight(metrics)).toBeCloseTo(23.4, 4);
});
it('snaps multiline auto spacing to whole document-grid lines', () => {
const metrics = getLineHeightMetrics(16, 0, 15.6, GridType.LINES, 1.5, SpacingRule.AUTO, BooleanNumber.TRUE, true, true, undefined, true);
expect(getLineBoxHeight(metrics)).toBeCloseTo(31.2, 4);
});
it('occupies enough whole document-grid lines for tall glyphs', () => {
const metrics = getLineHeightMetrics(28, 0, 20.8, GridType.LINES, 1, SpacingRule.AUTO, BooleanNumber.TRUE, true);
expect(getLineBoxHeight(metrics)).toBeCloseTo(41.6, 4);
});
it('does not apply line pitch for a character-only grid', () => {
const metrics = getLineHeightMetrics(16, 0, 30, GridType.SNAP_TO_CHARS, 1.5, SpacingRule.AUTO, BooleanNumber.TRUE, true);
expect(getLineBoxHeight(metrics)).toBeCloseTo(24, 4);
});
it('treats at-least spacing as a minimum line box height', () => {
const compactMetrics = getLineHeightMetrics(16, 0, 15.6, GridType.LINES, 10, SpacingRule.AT_LEAST, BooleanNumber.FALSE, true);
const expandedMetrics = getLineHeightMetrics(16, 0, 15.6, GridType.LINES, 40, SpacingRule.AT_LEAST, BooleanNumber.FALSE, true);
@@ -22,13 +22,16 @@ import {
DataStreamTreeTokenType,
DocumentBlockRangeType,
DocumentFlavor,
GridType,
HorizontalAlign,
ObjectRelativeFromH,
ObjectRelativeFromV,
PositionedObjectLayoutType,
SpacingRule,
WrapTextType,
} from '@univerjs/core';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { GlyphType } from '../../../../../../basics/i-document-skeleton-cached';
import { setDocsCustomBlockRenderViewportProvider } from '../../../../custom-block-render-viewport';
import { updateInlineDrawingCoordsAndBorder } from '../../../tools';
import { lineBreaking } from '../linebreaking';
@@ -104,6 +107,88 @@ describe('linebreaking', () => {
expect(result[0].sections.length).toBeGreaterThan(0);
});
it.each([
{ name: 'explicitly disabled', snapToGrid: BooleanNumber.FALSE, expectedLineHeight: 21 },
{ name: 'enabled by default', snapToGrid: undefined, expectedLineHeight: 41.6 },
])('keeps document-grid multiline spacing $name outside a table', ({ snapToGrid, expectedLineHeight }) => {
const content = '一'.repeat(30);
const { viewModel, ctx, paragraphNode, sectionBreakConfig, curPage } = createParagraphLayoutTestBed(content, {
documentStyle: {
documentFlavor: DocumentFlavor.TRADITIONAL,
gridType: GridType.LINES,
linePitch: 20.8,
pageSize: { width: 160, height: 600 },
marginLeft: 20,
marginRight: 20,
},
body: {
paragraphs: [{
startIndex: content.length,
paragraphId: 'grid-paragraph',
paragraphStyle: {
lineSpacing: 1.5,
spacingRule: SpacingRule.AUTO,
...(snapToGrid == null ? {} : { snapToGrid }),
spaceBelow: { v: 10.4 },
},
}],
sectionBreaks: [{
sectionId: 'grid-section',
startIndex: content.length + 1,
linePitch: 20.8,
gridType: GridType.LINES,
}],
},
});
const result = lineBreaking(
ctx,
viewModel,
shaping(ctx, paragraphNode.content!, viewModel, paragraphNode, sectionBreakConfig),
curPage,
paragraphNode,
sectionBreakConfig,
null
);
const lines = paragraphLines(result[0], paragraphNode.endIndex);
expect(lines.length).toBeGreaterThan(1);
for (const line of lines) {
expect(line.lineHeight).toBeCloseTo(expectedLineHeight, 6);
}
});
it('suppresses paragraph space above at the top of a traditional page', () => {
const content = 'Heading';
const { viewModel, ctx, paragraphNode, sectionBreakConfig, curPage } = createParagraphLayoutTestBed(content, {
documentStyle: {
documentFlavor: DocumentFlavor.TRADITIONAL,
},
body: {
textRuns: [{ st: 0, ed: content.length, ts: { ff: 'Arial', fs: 14 } }],
paragraphs: [{
startIndex: content.length,
paragraphId: 'page-heading',
paragraphStyle: { spaceAbove: { v: 22 }, textStyle: { ff: 'Arial', fs: 14 } },
}],
},
});
const result = lineBreaking(
ctx,
viewModel,
shaping(ctx, paragraphNode.content!, viewModel, paragraphNode, sectionBreakConfig),
curPage,
paragraphNode,
sectionBreakConfig,
null
);
const firstLine = result[0].sections[0].columns[0].lines[0];
expect(firstLine.marginTop).toBe(0);
expect(firstLine.top).toBe(0);
});
it('starts pageBreakBefore paragraphs on the next physical page without doubling a blank page', () => {
const { viewModel, ctx, sectionNode, sectionBreakConfig, curPage } = createSectionLayoutTestBed(['First', 'Second'], {
documentStyle: {
@@ -174,6 +259,34 @@ describe('linebreaking', () => {
expect(blankResult).toHaveLength(1);
});
it('promotes a first-cell pageBreakBefore to the table wrapper paragraph', () => {
const { viewModel, ctx, paragraphNode, sectionBreakConfig, curPage } = createParagraphLayoutTestBed('Before', {
documentStyle: { documentFlavor: DocumentFlavor.TRADITIONAL },
});
const firstPages = lineBreaking(
ctx,
viewModel,
shaping(ctx, paragraphNode.content!, viewModel, paragraphNode, sectionBreakConfig),
curPage,
paragraphNode,
sectionBreakConfig,
null
);
const result = lineBreaking(
ctx,
viewModel,
[],
firstPages[firstPages.length - 1],
paragraphNode,
sectionBreakConfig,
null,
true
);
expect(result).toHaveLength(2);
});
it('does not duplicate a page boundary already created by an explicit page break', () => {
const firstContent = `Before${DataStreamTreeTokenType.PAGE_BREAK}`;
const secondContent = 'Chapter';
@@ -231,6 +344,362 @@ describe('linebreaking', () => {
expect(paragraphLines(result[0], secondParagraph.endIndex).length).toBeGreaterThan(0);
});
it('does not create a blank page for a manual page break at the top of an empty page', () => {
const testBed = createParagraphLayoutTestBed(DataStreamTreeTokenType.PAGE_BREAK, {
documentStyle: { documentFlavor: DocumentFlavor.TRADITIONAL },
});
const result = lineBreaking(
testBed.ctx,
testBed.viewModel,
shaping(
testBed.ctx,
testBed.paragraphNode.content!,
testBed.viewModel,
testBed.paragraphNode,
testBed.sectionBreakConfig
),
testBed.curPage,
testBed.paragraphNode,
testBed.sectionBreakConfig,
null
);
expect(result).toHaveLength(1);
});
it('renders a list marker only once when a bullet paragraph ends with a manual page break', () => {
const content = `Item 6${DataStreamTreeTokenType.PAGE_BREAK}`;
const testBed = createParagraphLayoutTestBed(content, {
documentStyle: { documentFlavor: DocumentFlavor.TRADITIONAL },
body: {
paragraphs: [{
startIndex: content.length,
bullet: { listId: 'list-1', listType: 'test-list', nestingLevel: 0 },
}],
},
lists: {
'test-list': {
listType: 'test-list',
nestingLevel: [{
bulletAlignment: 1,
glyphFormat: '%1)',
startNumber: 1,
glyphType: 0,
}],
},
},
});
const result = lineBreaking(
testBed.ctx,
testBed.viewModel,
shaping(
testBed.ctx,
testBed.paragraphNode.content!,
testBed.viewModel,
testBed.paragraphNode,
testBed.sectionBreakConfig
),
testBed.curPage,
testBed.paragraphNode,
testBed.sectionBreakConfig,
null
);
const listGlyphCounts = result.map((page) =>
page.sections.reduce((pageCount, section) =>
pageCount + section.columns.reduce((sectionCount, column) =>
sectionCount + column.lines.reduce((columnCount, line) =>
columnCount + line.divides.reduce((lineCount, divide) =>
lineCount + divide.glyphGroup.filter((glyph) => glyph.glyphType === GlyphType.LIST).length, 0), 0), 0), 0)
);
expect(result).toHaveLength(2);
expect(listGlyphCounts).toEqual([1, 0]);
});
it('does not add a second boundary when a rendered page break follows natural overflow', () => {
const beforeBreak = 'One two three four five six seven eight nine ten '.repeat(8);
const content = `${beforeBreak}${DataStreamTreeTokenType.PAGE_BREAK}After`;
const testBed = createParagraphLayoutTestBed(content, {
documentStyle: {
documentFlavor: DocumentFlavor.TRADITIONAL,
pageSize: { width: 120, height: 100 },
marginTop: 20,
marginBottom: 20,
marginLeft: 20,
marginRight: 20,
},
body: {
renderedPageBreaks: [beforeBreak.length],
},
});
const manualBed = createParagraphLayoutTestBed(content, {
documentStyle: {
documentFlavor: DocumentFlavor.TRADITIONAL,
pageSize: { width: 120, height: 100 },
marginTop: 20,
marginBottom: 20,
marginLeft: 20,
marginRight: 20,
},
});
const layout = (bed: typeof testBed) => lineBreaking(
bed.ctx,
bed.viewModel,
shaping(bed.ctx, bed.paragraphNode.content!, bed.viewModel, bed.paragraphNode, bed.sectionBreakConfig),
bed.curPage,
bed.paragraphNode,
bed.sectionBreakConfig,
null
);
const result = layout(testBed);
const manualResult = layout(manualBed);
expect(result.length).toBeGreaterThan(1);
expect(result).toHaveLength(manualResult.length - 1);
expect(result.at(-1)?.sections.some((section) =>
section.columns.some((column) => !column.isFull)
)).toBe(true);
});
it('preserves a rendered page break before a fitting inline drawing', () => {
const beforeBreak = 'Intro';
const content = `${beforeBreak}${DataStreamTreeTokenType.PAGE_BREAK}${DataStreamTreeTokenType.CUSTOM_BLOCK}`;
const createBed = (rendered: boolean) => createParagraphLayoutTestBed(content, {
documentStyle: {
documentFlavor: DocumentFlavor.TRADITIONAL,
pageSize: { width: 400, height: 600 },
marginTop: 20,
marginBottom: 20,
marginLeft: 20,
marginRight: 20,
},
body: {
customBlocks: [{ startIndex: beforeBreak.length + 1, blockId: 'inline-image' }],
...(rendered ? { renderedPageBreaks: [beforeBreak.length] } : {}),
},
drawings: {
'inline-image': {
drawingId: 'inline-image',
layoutType: PositionedObjectLayoutType.INLINE,
docTransform: {
angle: 0,
positionH: {},
positionV: {},
// DOCX EMU-to-pixel conversion can leave a sub-pixel width over the column.
size: { width: 360.4, height: 90 },
},
},
},
});
const layout = (bed: ReturnType<typeof createBed>) => lineBreaking(
bed.ctx,
bed.viewModel,
shaping(bed.ctx, bed.paragraphNode.content!, bed.viewModel, bed.paragraphNode, bed.sectionBreakConfig),
bed.curPage,
bed.paragraphNode,
bed.sectionBreakConfig,
null
);
const renderedResult = layout(createBed(true));
const manualResult = layout(createBed(false));
expect(renderedResult).toHaveLength(manualResult.length);
expect({
rendered: paginationSignature(renderedResult),
manual: paginationSignature(manualResult),
}).toMatchInlineSnapshot(`
{
"manual": [
{
"breakType": 0,
"pageNumber": 1,
"sections": [
{
"columns": [
{
"lines": [
{
"divideCount": 1,
"lineIndex": 0,
"paragraphIndex": 7,
"top": 0,
},
],
},
],
},
],
},
{
"breakType": 1,
"pageNumber": 2,
"sections": [
{
"columns": [
{
"lines": [
{
"divideCount": 1,
"lineIndex": 0,
"paragraphIndex": 7,
"top": 0,
},
{
"divideCount": 1,
"lineIndex": 1,
"paragraphIndex": 7,
"top": 90,
},
],
},
],
},
],
},
],
"rendered": [
{
"breakType": 0,
"pageNumber": 1,
"sections": [
{
"columns": [
{
"lines": [
{
"divideCount": 1,
"lineIndex": 0,
"paragraphIndex": 7,
"top": 0,
},
],
},
],
},
],
},
{
"breakType": 1,
"pageNumber": 2,
"sections": [
{
"columns": [
{
"lines": [
{
"divideCount": 1,
"lineIndex": 0,
"paragraphIndex": 7,
"top": 0,
},
{
"divideCount": 1,
"lineIndex": 1,
"paragraphIndex": 7,
"top": 90,
},
],
},
],
},
],
},
],
}
`);
});
it('does not duplicate a rendered page boundary already reached by an earlier paragraph', () => {
const overflowingParagraph = 'One two three four five six seven eight nine ten '.repeat(8);
const beforeBreak = 'Short paragraph';
const breakParagraph = `${beforeBreak}${DataStreamTreeTokenType.PAGE_BREAK}After`;
const renderedBreakIndex = overflowingParagraph.length + 1 + beforeBreak.length;
const testBed = createSectionLayoutTestBed([overflowingParagraph, breakParagraph], {
documentStyle: {
documentFlavor: DocumentFlavor.TRADITIONAL,
pageSize: { width: 120, height: 120 },
marginTop: 20,
marginBottom: 20,
marginLeft: 20,
marginRight: 20,
},
body: {
renderedPageBreaks: [renderedBreakIndex],
},
});
const [firstParagraph, secondParagraph] = testBed.sectionNode.children;
const firstPages = lineBreaking(
testBed.ctx,
testBed.viewModel,
shaping(
testBed.ctx,
firstParagraph.content!,
testBed.viewModel,
firstParagraph,
testBed.sectionBreakConfig
),
testBed.curPage,
firstParagraph,
testBed.sectionBreakConfig,
null
);
const secondPages = lineBreaking(
testBed.ctx,
testBed.viewModel,
shaping(
testBed.ctx,
secondParagraph.content!,
testBed.viewModel,
secondParagraph,
testBed.sectionBreakConfig
),
firstPages.at(-1)!,
secondParagraph,
testBed.sectionBreakConfig,
null
);
expect(firstPages.length).toBeGreaterThan(1);
expect(firstPages.at(-1)?.sections.some((section) =>
section.columns.some((column) => !column.isFull)
)).toBe(true);
expect(secondPages).toHaveLength(1);
expect(paragraphLines(secondPages[0], secondParagraph.endIndex).length).toBeGreaterThan(0);
});
it('exposes cell-local rendered page breaks to the outer table paginator', () => {
const beforeBreak = 'Before';
const content = `${beforeBreak}${DataStreamTreeTokenType.PAGE_BREAK}After`;
const testBed = createParagraphLayoutTestBed(content, {
documentStyle: { documentFlavor: DocumentFlavor.TRADITIONAL },
body: {
renderedPageBreaks: [beforeBreak.length],
tables: [{ startIndex: 0, endIndex: content.length, tableId: 'table-1' }],
},
});
const result = lineBreaking(
testBed.ctx,
testBed.viewModel,
shaping(
testBed.ctx,
testBed.paragraphNode.content!,
testBed.viewModel,
testBed.paragraphNode,
testBed.sectionBreakConfig
),
testBed.curPage,
testBed.paragraphNode,
testBed.sectionBreakConfig,
null
);
expect(result).toHaveLength(2);
});
it.each([
{ name: 'modern', flavor: DocumentFlavor.MODERN },
{ name: 'unspecified', flavor: DocumentFlavor.UNSPECIFIED },
@@ -349,7 +818,7 @@ describe('linebreaking', () => {
expect(result[0].sections[0].columns).toHaveLength(2);
});
it('moves a split keepLines paragraph when it fits on an empty page', () => {
it('DOCX golden e2e moves a split keepLines paragraph when it fits on an empty page', () => {
const contents = ['Filler filler filler filler', 'One two three four five'];
const firstEnd = contents[0].length;
const secondEnd = firstEnd + 1 + contents[1].length;
@@ -452,7 +921,7 @@ describe('linebreaking', () => {
expect(paragraphLines(lastResult[1], followingIndex).length).toBeGreaterThan(0);
});
it('moves a bounded keepNext chain and stops at a manual break', () => {
it('DOCX golden e2e moves a bounded keepNext chain and stops at a manual break', () => {
const contents = [
'Prefix prefix prefix prefix prefix prefix',
'Heading one',
@@ -620,7 +1089,7 @@ describe('linebreaking', () => {
expect(testBed.ctx.paginationMetrics?.retryCount).toBeLessThanOrEqual(contents.length);
});
it('avoids a single natural widow line and lets oversized keepLines paragraphs terminate', () => {
it('DOCX golden e2e avoids a single natural widow line and lets oversized keepLines paragraphs terminate', () => {
const content = 'One two three four five six seven eight nine ten eleven twelve';
const widowBed = createParagraphLayoutTestBed(content, {
documentStyle: {
@@ -696,7 +1165,7 @@ describe('linebreaking', () => {
paragraphLines(page, oversizedBed.paragraphNode.endIndex).length > 0)).toBe(true);
});
it('does not soften a manual page break with keep or widow constraints', () => {
it('DOCX golden e2e does not soften a manual page break with keep or widow constraints', () => {
const content = `Before${DataStreamTreeTokenType.PAGE_BREAK}After`;
const { viewModel, ctx, paragraphNode, sectionBreakConfig, curPage } = createParagraphLayoutTestBed(content, {
documentStyle: {
@@ -791,6 +1260,34 @@ describe('linebreaking', () => {
const second = runLayout();
expect(second.signature).toEqual(first.signature);
expect(first.signature).toEqual([
{
pageNumber: 2,
breakType: 0,
sections: [{
columns: [{
lines: [
{ paragraphIndex: 41, lineIndex: 0, top: 0, divideCount: 1 },
{ paragraphIndex: 41, lineIndex: 1, top: 14, divideCount: 1 },
],
}],
}],
},
{
pageNumber: 3,
breakType: 0,
sections: [{
columns: [{
lines: [
{ paragraphIndex: 49, lineIndex: 0, top: 0, divideCount: 1 },
{ paragraphIndex: 75, lineIndex: 1, top: 14, divideCount: 1 },
{ paragraphIndex: 75, lineIndex: 2, top: 28, divideCount: 1 },
{ paragraphIndex: 75, lineIndex: 3, top: 42, divideCount: 1 },
],
}],
}],
},
]);
expect(first.metrics.noConstraintParagraphs).toBeGreaterThan(0);
expect(first.metrics.constrainedParagraphs).toBeGreaterThan(0);
expect(first.metrics.retryCount).toBeLessThanOrEqual(first.paragraphCount);
@@ -1033,6 +1530,37 @@ describe('linebreaking', () => {
expect(textLine?.top).toBeGreaterThanOrEqual((drawing?.aTop ?? 0) + (drawing?.height ?? 0));
});
it('keeps inline custom block height when the paragraph terminator relayouts its line', () => {
const content = DataStreamTreeTokenType.CUSTOM_BLOCK;
const { viewModel, ctx, paragraphNode, sectionBreakConfig, curPage } = createParagraphLayoutTestBed(content, {
body: {
customBlocks: [{ startIndex: 0, blockId: 'b1' }],
},
documentStyle: {
documentFlavor: DocumentFlavor.TRADITIONAL,
gridType: GridType.LINES,
linePitch: 20.8,
},
drawings: {
b1: {
drawingId: 'b1',
layoutType: PositionedObjectLayoutType.INLINE,
docTransform: {
angle: 0,
size: { width: 200, height: 316.8 },
},
},
},
});
const shapedTextList = shaping(ctx, paragraphNode.content!, viewModel, paragraphNode, sectionBreakConfig);
const result = lineBreaking(ctx, viewModel, shapedTextList, curPage, paragraphNode, sectionBreakConfig, null);
const line = paragraphLines(result[0], paragraphNode.endIndex)[0];
expect(line.contentHeight).toBeCloseTo(316.8, 4);
expect(line.lineHeight).toBeCloseTo(316.8, 4);
});
it('ignores custom blocks that reference missing drawings', () => {
const content = `A${DataStreamTreeTokenType.CUSTOM_BLOCK}B`;
const { viewModel, ctx, paragraphNode, sectionBreakConfig, curPage } = createParagraphLayoutTestBed(content, {
@@ -1048,7 +1576,7 @@ describe('linebreaking', () => {
expect(result.length).toBeGreaterThanOrEqual(1);
});
it('honors page breaks in paragraphs that only contain floating custom blocks', () => {
it('DOCX golden e2e honors page breaks in paragraphs that only contain floating custom blocks', () => {
const content = `${DataStreamTreeTokenType.CUSTOM_BLOCK}${DataStreamTreeTokenType.CUSTOM_BLOCK}${DataStreamTreeTokenType.PAGE_BREAK}`;
const { viewModel, ctx, paragraphNode, sectionBreakConfig, curPage } = createParagraphLayoutTestBed(content, {
body: {
@@ -1141,7 +1669,7 @@ describe('linebreaking', () => {
expect(result[1].skeDrawings.has('checklist-panel')).toBe(true);
});
it('keeps every floating custom block on its side of a page break', () => {
it('DOCX golden e2e keeps every floating custom block on its side of a page break', () => {
const firstPageIds = Array.from({ length: 15 }, (_, index) => `cover-${index + 1}`);
const secondPageIds = Array.from({ length: 14 }, (_, index) => `content-${index + 1}`);
const drawingIds = [...firstPageIds, ...secondPageIds];
@@ -1302,6 +1830,54 @@ describe('linebreaking', () => {
expect(inlinePhoto?.aLeft).toBeGreaterThanOrEqual((leftWrap?.aLeft ?? 0) + (leftWrap?.width ?? 0));
});
it('DOCX golden e2e keeps an inline drawing on the same page as an adjacent top-bottom floating drawing in the same paragraph', () => {
const content = `${DataStreamTreeTokenType.CUSTOM_BLOCK}${DataStreamTreeTokenType.CUSTOM_BLOCK}`;
const { viewModel, ctx, paragraphNode, sectionBreakConfig, curPage } = createParagraphLayoutTestBed(content, {
documentStyle: {
documentFlavor: DocumentFlavor.TRADITIONAL,
},
body: {
customBlocks: [
{ startIndex: 0, blockId: 'floating-contract-page' },
{ startIndex: 1, blockId: 'inline-contract-page' },
],
},
drawings: {
'floating-contract-page': {
drawingId: 'floating-contract-page',
layoutType: PositionedObjectLayoutType.WRAP_TOP_AND_BOTTOM,
docTransform: {
size: { width: 160, height: 300 },
positionH: { relativeFrom: ObjectRelativeFromH.COLUMN, posOffset: 20 },
positionV: { relativeFrom: ObjectRelativeFromV.PARAGRAPH, posOffset: 20 },
angle: 0,
},
},
'inline-contract-page': {
drawingId: 'inline-contract-page',
layoutType: PositionedObjectLayoutType.INLINE,
docTransform: {
size: { width: 160, height: 300 },
positionH: {},
positionV: {},
angle: 0,
},
},
},
});
const shapedTextList = shaping(ctx, paragraphNode.content!, viewModel, paragraphNode, sectionBreakConfig);
const result = lineBreaking(ctx, viewModel, shapedTextList, curPage, paragraphNode, sectionBreakConfig, null);
updateInlineDrawingCoordsAndBorder(ctx, result);
expect(result).toHaveLength(1);
expect(result[0].skeDrawings.has('floating-contract-page')).toBe(true);
expect(result[0].skeDrawings.has('inline-contract-page')).toBe(true);
const floatingDrawing = result[0].skeDrawings.get('floating-contract-page')!;
const inlineDrawing = result[0].skeDrawings.get('inline-contract-page')!;
expect(inlineDrawing.aTop).toBeGreaterThanOrEqual(floatingDrawing.aTop + floatingDrawing.height);
});
it('does not move a zero-width wrap-none floating anchor to a new page at the page bottom', () => {
const content = DataStreamTreeTokenType.CUSTOM_BLOCK;
const { viewModel, ctx, paragraphNode, sectionBreakConfig, curPage } = createParagraphLayoutTestBed(content, {
@@ -1515,7 +2091,15 @@ describe('linebreaking', () => {
const result = lineBreaking(ctx, viewModel, shapedTextList, curPage, paragraphNode, sectionBreakConfig, null);
expect(result).toHaveLength(1);
const renderedText = result[0].sections[0].columns[0].lines
const lines = result[0].sections[0].columns[0].lines;
const textLines = lines.map((line) => line.divides
.flatMap((divide) => divide.glyphGroup)
.map((glyph) => glyph.content)
.join(''));
expect(textLines.findIndex((line) => line.includes('PROGRAM')))
.not
.toBe(textLines.findIndex((line) => line.includes('SECOND')));
const renderedText = lines
.flatMap((line) => line.divides)
.flatMap((divide) => divide.glyphGroup)
.map((glyph) => glyph.content)
@@ -1537,7 +2121,7 @@ describe('linebreaking', () => {
rangeId: 'docx-break-0',
rangeType: 5,
wholeEntity: true,
properties: { docxBreakType: 'column' },
properties: { breakType: 'column' },
}],
sectionBreaks: [{ sectionId: 'section_fixture_1021', startIndex: content.length + 1, columnProperties: [
{ width: 170, paddingEnd: 20 },
@@ -1565,7 +2149,7 @@ describe('linebreaking', () => {
expect(secondColumnText).toContain('SECOND');
});
it('wraps second-column text around a drawing anchored below a preceding section', () => {
it('DOCX golden e2e wraps second-column text around a drawing anchored below a preceding section', () => {
const content = `${DataStreamTreeTokenType.CUSTOM_BLOCK}FIRST${DataStreamTreeTokenType.COLUMN_BREAK}ments without duplicating content in the body`;
const { viewModel, ctx, paragraphNode, sectionBreakConfig, curPage } = createParagraphLayoutTestBed(content, {
documentStyle: {
@@ -1584,7 +2168,7 @@ describe('linebreaking', () => {
rangeId: 'cross-column-break',
rangeType: 5,
wholeEntity: true,
properties: { docxBreakType: 'column' },
properties: { breakType: 'column' },
}],
sectionBreaks: [{
sectionId: 'section_fixture_cross_column_wrap',
@@ -1639,7 +2223,7 @@ describe('linebreaking', () => {
rangeId: 'cross-column-top-bottom-break',
rangeType: 5,
wholeEntity: true,
properties: { docxBreakType: 'column' },
properties: { breakType: 'column' },
}],
sectionBreaks: [{
sectionId: 'section_fixture_cross_column_top_bottom',
@@ -1778,7 +2362,7 @@ describe('linebreaking', () => {
});
});
it('positions DOCX floating anchors in empty paragraphs from the following text paragraph indent origin', () => {
it('DOCX golden e2e positions DOCX floating anchors in empty paragraphs from the following text paragraph indent origin', () => {
const floatingParagraph = DataStreamTreeTokenType.CUSTOM_BLOCK;
const bodyParagraph = 'Body';
const { viewModel, ctx, sectionNode, sectionBreakConfig, curPage } = createSectionLayoutTestBed([floatingParagraph, bodyParagraph], {
@@ -23,6 +23,30 @@ import { shaping } from '../shaping';
import { createParagraphLayoutTestBed } from './create-paragraph-layout-test-bed';
describe('shaping', () => {
it('uses paragraph text style for an empty traditional paragraph mark', () => {
const { viewModel, ctx, paragraphNode, sectionBreakConfig } = createParagraphLayoutTestBed('', {
documentStyle: {
documentFlavor: 1,
textStyle: { ff: 'Arial', fs: 11 },
},
body: {
textRuns: [],
paragraphs: [{
startIndex: 0,
paragraphId: 'compact-empty-paragraph',
paragraphStyle: { textStyle: { ff: 'Arial', fs: 3 } },
}],
},
});
const glyphs = shaping(ctx, paragraphNode.content!, viewModel, paragraphNode, sectionBreakConfig)
.flatMap((item) => item.glyphs);
const paragraphMark = glyphs.find((glyph) => glyph.streamType === DataStreamTreeTokenType.PARAGRAPH);
expect(paragraphMark?.fontStyle?.originFontSize).toBe(3);
expect(paragraphMark?.ts).toMatchObject({ ff: 'Arial', fs: 3 });
});
it('shapes plain English text', () => {
const { viewModel, ctx, paragraphNode, sectionBreakConfig } = createParagraphLayoutTestBed('Hello world');
@@ -41,11 +41,13 @@ import {
PositionedObjectLayoutType,
SpacingRule,
TableTextWrapType,
TabStopAlignment,
WrapStrategy,
} from '@univerjs/core';
import { GlyphType, LineType } from '../../../../../basics/i-document-skeleton-cached';
import { isCjkLeftAlignedPunctuation } from '../../../../../basics/tools';
import { getDocsCustomBlockRenderViewport } from '../../../custom-block-render-viewport';
import { isTraditionalDocumentCompatibility } from '../../../document-compatibility';
import { BreakPointType } from '../../line-breaker/break';
import { addGlyphToDivide, createSkeletonBulletGlyph } from '../../model/glyph';
import {
@@ -74,6 +76,7 @@ import {
isBlankColumn,
isColumnFull,
lineIterator,
reachesNextDocumentGridLine,
} from '../../tools';
import { createTableSkeletons, getTableLeft, rollbackListCache } from '../table';
@@ -120,11 +123,12 @@ export function layoutParagraph(
sectionBreakConfig: ISectionBreakConfig,
paragraphConfig: IParagraphConfig,
isParagraphFirstShapedText: boolean,
breakPointType = BreakPointType.Normal
breakPointType = BreakPointType.Normal,
renderBullet = isParagraphFirstShapedText
) {
if (isParagraphFirstShapedText) {
// elementIndex === 0 means the first character at the beginning of a paragraph, needs a new line to distinguish from the previous paragraph
if (paragraphConfig.bulletSkeleton) {
if (renderBullet && paragraphConfig.bulletSkeleton) {
const { bulletSkeleton, paragraphStyle = {} } = paragraphConfig;
// If it is the beginning of a paragraph, bullet needs to be added
const { gridType = GridType.LINES, charSpace = 0, defaultTabStop = 10.5 } = sectionBreakConfig;
@@ -157,6 +161,13 @@ export function layoutParagraph(
_divideOperator(ctx, glyphGroup, pages, sectionBreakConfig, paragraphConfig, isParagraphFirstShapedText, breakPointType);
}
if (breakPointType === BreakPointType.Mandatory) {
const divideInfo = getLastNotFullDivideInfo(getLastPage(pages));
if (divideInfo) {
updateDivideInfo(divideInfo.divide, { isFull: true, breakType: breakPointType });
}
}
return [...pages];
}
@@ -273,6 +284,7 @@ function _divideOperator(
const divideInfo = getLastNotFullDivideInfo(lastPage); // Get the first divide in the latest line that is not full.
if (divideInfo) {
const { divide, isLast } = divideInfo;
_adjustExplicitTabStop(divide, glyphGroup, paragraphConfig);
const lastGlyph = divide?.glyphGroup?.[divide.glyphGroup.length - 1];
const lastWidth = lastGlyph?.width || 0;
const lastLeft = lastGlyph?.left || 0;
@@ -559,6 +571,49 @@ function _divideOperator(
}
}
function _adjustExplicitTabStop(
divide: IDocumentSkeletonDivide,
followingGlyphs: IDocumentSkeletonGlyph[],
paragraphConfig: IParagraphConfig
): void {
const tabGlyph = divide.glyphGroup[divide.glyphGroup.length - 1];
if (tabGlyph?.glyphType !== GlyphType.TAB) {
return;
}
const tabStops = paragraphConfig.paragraphStyle?.tabStops;
if (!tabStops?.length) {
return;
}
const tabStop = [...tabStops]
.sort((left, right) => left.offset - right.offset)
.find(({ offset }) => offset > tabGlyph.left);
if (!tabStop) {
return;
}
let followingWidth = 0;
for (const glyph of followingGlyphs) {
followingWidth += glyph.width;
}
const alignmentOffset = tabStop.alignment === TabStopAlignment.END
? followingWidth
: tabStop.alignment === TabStopAlignment.CENTER
? followingWidth / 2
: 0;
const targetOffset = Math.min(tabStop.offset, divide.width);
const width = targetOffset - tabGlyph.left - alignmentOffset;
if (width <= 0) {
return;
}
tabGlyph.width = width;
tabGlyph.bBox.width = width;
tabGlyph.tabLeader = tabStop.leader;
}
function _lineOperator(
ctx: ILayoutContext,
glyphGroup: IDocumentSkeletonGlyph[],
@@ -607,6 +662,7 @@ function _lineOperator(
const ascent = Math.max(...glyphGroup.map((glyph) => glyph.bBox.ba));
const descent = Math.max(...glyphGroup.map((glyph) => glyph.bBox.bd));
const glyphLineHeight = defaultSpanMetrics?.lineHeight || (ascent + descent);
const normalLineHeight = Math.max(...glyphGroup.map((glyph) => glyph.bBox.normalLineHeight ?? 0)) || undefined;
const {
paragraphStyle: originParagraphStyle = {},
@@ -647,9 +703,13 @@ function _lineOperator(
sectionBreakConfig,
paragraphConfig
);
const hasInlineCustomBlock = defaultSpanMetrics?.hasInlineCustomBlock ||
glyphGroup.some((glyph) => glyph.streamType === DataStreamTreeTokenType.CUSTOM_BLOCK && glyph.width !== 0);
const snapMultilineParagraphToWholeGrid = snapToGrid === BooleanNumber.TRUE &&
!isParagraphFirstShapedText &&
!hasInlineCustomBlock &&
reachesNextDocumentGridLine(lineSpacing, getNumberUnitValue(spaceBelow, lineSpacing), linePitch) &&
isTraditionalDocumentCompatibility(paragraphConfig.documentCompatibilityPolicy!);
const positionedCustomBlockOnly = glyphGroup.length > 0 &&
paragraphNonInlineSkeDrawings != null &&
paragraphNonInlineSkeDrawings.size > 0 &&
@@ -676,9 +736,40 @@ function _lineOperator(
spacingRule,
snapToGrid,
paragraphConfig.useWordStyleLineHeight,
!hasInlineCustomBlock
!hasInlineCustomBlock,
normalLineHeight,
snapMultilineParagraphToWholeGrid
);
if (snapMultilineParagraphToWholeGrid && preLine?.paragraphIndex === paragraphIndex) {
const preLineGlyphs = __getGlyphGroupByLine(preLine);
const preLineHasInlineCustomBlock = preLineGlyphs.some(
(glyph) => glyph.streamType === DataStreamTreeTokenType.CUSTOM_BLOCK && glyph.width !== 0
);
if (__hasFlowGlyph(preLineGlyphs) && !preLineHasInlineCustomBlock) {
const preLineMetrics = getLineHeightMetrics(
preLine.contentHeight,
paragraphLineGapDefault,
linePitch,
gridType,
lineSpacing,
spacingRule,
snapToGrid,
paragraphConfig.useWordStyleLineHeight,
true,
undefined,
true
);
const heightDelta = preLineMetrics.lineSpacingApply -
(preLine.paddingTop + preLine.contentHeight + preLine.paddingBottom);
if (heightDelta > LINE_LAYOUT_OVERFLOW_TOLERANCE) {
preLine.paddingTop += heightDelta / 2;
preLine.paddingBottom += heightDelta / 2;
preLine.lineHeight += heightDelta;
}
}
}
if (positionedCustomBlockOnly) {
paddingTop = 0;
paddingBottom = 0;
@@ -692,7 +783,10 @@ function _lineOperator(
spaceAbove,
spaceBelow,
isParagraphFirstShapedText,
preLine
preLine,
isTraditionalDocumentCompatibility(paragraphConfig.documentCompatibilityPolicy!) &&
preLine == null &&
(column.parent?.top ?? 0) === 0
);
if (positionedCustomBlockOnly) {
@@ -773,6 +867,11 @@ function _lineOperator(
needOpenNewPageByTableLayout = _updateAndPositionTable(ctx, lineTop, lineHeight, lastPage, column, section, skeTablesInParagraph, paragraphConfig.paragraphIndex, sectionBreakConfig, pDrawingAnchor?.get(paragraphIndex)?.top);
}
const hasSameParagraphTopBottomDrawingWithInline = hasInlineCustomBlock &&
paragraphNonInlineSkeDrawings != null &&
[...paragraphNonInlineSkeDrawings.values()].some(
(drawing) => drawing.drawingOrigin.layoutType === PositionedObjectLayoutType.WRAP_TOP_AND_BOTTOM
);
const calculatedLineTop = positionedCustomBlockOnly
? lineTop
: calculateLineTopByDrawings(
@@ -792,9 +891,18 @@ function _lineOperator(
? calculatedLineTop
: Math.max(calculatedLineTop, previousTopBottomCustomBlockFlowBottom);
const lineOverflowsSection = lineHeight + newLineTop - section.height > LINE_LAYOUT_OVERFLOW_TOLERANCE;
// Word keeps an inline drawing below a top-bottom floating drawing from the
// same paragraph and clips the inline drawing at the physical page bottom.
const clipsSameParagraphInlineDrawing = hasSameParagraphTopBottomDrawingWithInline && newLineTop < section.height;
const lineOverflowsSection = !clipsSameParagraphInlineDrawing &&
lineHeight + newLineTop - section.height > LINE_LAYOUT_OVERFLOW_TOLERANCE;
if ((lineOverflowsSection && column.lines.length > 0 && lastPage.sections.length > 0) || needOpenNewPageByTableLayout) {
if (
(lineOverflowsSection &&
(column.lines.length > 0 || section.top > 0) &&
lastPage.sections.length > 0) ||
needOpenNewPageByTableLayout
) {
// Line height exceeds column height, and there is more than one line in the column, and there is more than one section;
// console.log('_lineOperator', { glyphGroup, pages, lineHeight, newLineTop, sectionHeight: section.height, lastPage });
setColumnFullState(column, true);
@@ -907,7 +1015,7 @@ function __updateAndPositionDrawings(
drawingAnchorLeft = 0,
skipRelayoutCheck = false,
overwriteTopBottomPosition = false
) {
): void {
if (targetDrawings.length === 0) {
return;
}
@@ -1121,7 +1229,10 @@ function _updateAndPositionTable(
const { top, left, height } = table;
const localTop = top - section.top;
if (!ctx.isDirty && localTop + height > section.height && firstUnPositionedTable.isSlideTable === false) {
if (
(localTop + height > section.height || table.hasPageBreak === true) &&
firstUnPositionedTable.isSlideTable === false
) {
// Need split table.
skeTablesInParagraph.pop();
const availableHeight = section.height - localTop;
@@ -1460,6 +1571,7 @@ export const __testing = {
isGlyphGroupBeyondDivideWidth,
checkPageBreak: __checkPageBreak,
updateAndPositionTable: _updateAndPositionTable,
adjustExplicitTabStop: _adjustExplicitTabStop,
};
function _columnOperator(
@@ -1495,7 +1607,14 @@ function _pageOperator(
const curSkeletonPage: IDocumentSkeletonPage = getLastPage(pages);
const { skeHeaders, skeFooters } = paragraphConfig;
pages.push(createSkeletonPage(ctx, sectionBreakConfig, { skeHeaders, skeFooters }, curSkeletonPage?.pageNumber + 1));
const nextPage = createSkeletonPage(
ctx,
sectionBreakConfig,
{ skeHeaders, skeFooters },
curSkeletonPage?.pageNumber + 1
);
nextPage.isNaturalPageOverflow = true;
pages.push(nextPage);
_columnOperator(ctx, glyphGroup, pages, sectionBreakConfig, paragraphConfig, isParagraphFirstShapedText, breakPointType, defaultSpanMetrics);
}
@@ -1518,12 +1637,12 @@ function __getIndentPadding(
let paddingLeft = indentStartNumber;
const paddingRight = indentEndNumber;
if (indentFirstLineNumber > 0 && isParagraphFirstShapedText) {
paddingLeft += indentFirstLineNumber;
}
if (hangingNumber > 0 && !isParagraphFirstShapedText) {
paddingLeft += hangingNumber;
if (isParagraphFirstShapedText) {
if (indentFirstLineNumber > 0) {
paddingLeft += indentFirstLineNumber;
} else if (hangingNumber > 0) {
paddingLeft -= hangingNumber;
}
}
return {
@@ -1538,7 +1657,8 @@ function __getParagraphSpace(
spaceAbove: Nullable<INumberUnit>,
spaceBelow: Nullable<INumberUnit>,
isParagraphFirstShapedText: boolean,
preLine?: IDocumentSkeletonLine
preLine?: IDocumentSkeletonLine,
suppressSpaceAbove = false
) {
// Unable to read the paragraph information from the previous line,
// So add the spaceBelowApply information to each line when creating a new line.
@@ -1546,7 +1666,7 @@ function __getParagraphSpace(
const spaceBelowApply = getNumberUnitValue(spaceBelow, lineSpacing);
if (isParagraphFirstShapedText) {
let marginTop = getNumberUnitValue(spaceAbove, lineSpacing);
let marginTop = suppressSpaceAbove ? 0 : getNumberUnitValue(spaceAbove, lineSpacing);
if (preLine) {
const { spaceBelowApply: preSpaceBelowApply } = preLine;
@@ -1600,13 +1720,16 @@ export function getLineHeightMetrics(
spacingRule: SpacingRule,
snapToGrid: BooleanNumber,
useWordStyleLineHeight = true,
scaleAutoLineSpacingByGlyphHeight = true
scaleAutoLineSpacingByGlyphHeight = true,
normalLineHeight?: number,
snapAutoLineSpacingToWholeGridLines = false
) {
const usesLineGridType = gridType === GridType.LINES || gridType === GridType.LINES_AND_CHARS;
if (!useWordStyleLineHeight) {
let paddingTop = paragraphLineGapDefault;
let paddingBottom = paragraphLineGapDefault;
if (gridType === GridType.DEFAULT || snapToGrid === BooleanNumber.FALSE) {
if (!usesLineGridType || snapToGrid === BooleanNumber.FALSE) {
if (spacingRule === SpacingRule.AUTO) {
return {
paddingTop,
@@ -1648,17 +1771,25 @@ export function getLineHeightMetrics(
const usesDocumentGrid =
spacingRule === SpacingRule.AUTO
&& snapToGrid === BooleanNumber.TRUE
&& gridType !== GridType.DEFAULT;
&& usesLineGridType;
if (spacingRule === SpacingRule.AUTO) {
const gridLineSpacing = snapAutoLineSpacingToWholeGridLines
? Math.ceil(lineSpacing - 1e-6) * linePitch
: lineSpacing * linePitch;
let lineSpacingApply = usesDocumentGrid
? lineSpacing * linePitch
? scaleAutoLineSpacingByGlyphHeight
? glyphLineHeight > gridLineSpacing + 1e-6
? Math.ceil((glyphLineHeight - 1e-6) / linePitch) * linePitch
: gridLineSpacing
: Math.max(glyphLineHeight, gridLineSpacing)
: scaleAutoLineSpacingByGlyphHeight
? lineSpacing * glyphLineHeight
? lineSpacing * Math.max(glyphLineHeight, normalLineHeight ?? 0)
: glyphLineHeight;
if (
!usesDocumentGrid
&& scaleAutoLineSpacingByGlyphHeight
&& normalLineHeight == null
&& lineSpacing <= 1.05
&& glyphLineHeight >= 30
) {
@@ -1686,9 +1817,12 @@ export function getLineHeightMetrics(
};
}
const exactLineSpacingApply = snapToGrid === BooleanNumber.TRUE && gridType !== GridType.DEFAULT
let exactLineSpacingApply = snapToGrid === BooleanNumber.TRUE && usesLineGridType
? Math.max(lineSpacing, linePitch)
: lineSpacing;
if (!scaleAutoLineSpacingByGlyphHeight) {
exactLineSpacingApply = Math.max(exactLineSpacingApply, glyphLineHeight);
}
// EXACT follows the requested line box height even when it is smaller than the glyph box.
// Negative padding lets subsequent lines advance by the exact value, which is closer to Word.
@@ -1758,7 +1892,6 @@ export function updateInlineDrawingPosition(
});
const drawingWidth = viewport?.width ?? width;
const drawingHeight = viewport?.height ?? height;
drawing.aLeft = viewport
? blockLeft + (viewport.offsetLeft ?? 0)
: blockLeft + 0.5 * glyph.width - 0.5 * drawingWidth || 0;
@@ -67,10 +67,44 @@ function _endsWithToken(text: string, glyphs: IDocumentSkeletonGlyph[], token: D
return text.endsWith(token) || glyphs[glyphs.length - 1]?.raw === token || glyphs[glyphs.length - 1]?.streamType === token;
}
function _isMarkedDocxColumnBreak(viewModel: DocumentViewModel, absoluteIndex: number): boolean {
function _isRenderedPageBreak(viewModel: DocumentViewModel, absoluteIndex: number): boolean {
return viewModel.getBody?.()?.renderedPageBreaks?.includes(absoluteIndex) === true;
}
function _hasReachedRenderedPageBreak(
viewModel: DocumentViewModel,
absoluteIndex: number,
currentPage: IDocumentSkeletonPage
): boolean {
const body = viewModel.getBody?.();
const renderedBreakIndex = body?.renderedPageBreaks?.indexOf(absoluteIndex) ?? -1;
if (renderedBreakIndex < 0) {
return false;
}
// Page-number restarts make the visible page number different from the physical page ordinal.
// Keep the conservative boundary behavior until the skeleton exposes a physical page index.
const hasPageNumberRestart = body?.sectionBreaks?.some(
(sectionBreak) => sectionBreak.startIndex <= absoluteIndex && sectionBreak.pageNumberStart != null
) === true;
if (hasPageNumberRestart) {
return false;
}
const targetPageNumber = currentPage.pageNumberStart + renderedBreakIndex + 1;
return currentPage.pageNumber >= targetPageNumber;
}
function _isInsideFlowTable(viewModel: DocumentViewModel, absoluteIndex: number): boolean {
return viewModel.getBody?.()?.tables?.some(
(table) => table.startIndex <= absoluteIndex && absoluteIndex < table.endIndex
) === true;
}
function _isMarkedColumnBreak(viewModel: DocumentViewModel, absoluteIndex: number): boolean {
const customRange = viewModel.getCustomRange(absoluteIndex);
return customRange?.properties?.docxBreakType === DocxBreakType.COLUMN;
return customRange?.properties?.breakType === DocxBreakType.COLUMN;
}
function _glyphCount(glyphs: IDocumentSkeletonGlyph[]): number {
@@ -700,7 +734,8 @@ export function lineBreaking(
curPage: IDocumentSkeletonPage,
paragraphNode: DataStreamTreeNode,
sectionBreakConfig: ISectionBreakConfig,
tableSkeleton: Nullable<IDocumentSkeletonTable>
tableSkeleton: Nullable<IDocumentSkeletonTable>,
tablePageBreakBefore = false
): IDocumentSkeletonPage[] {
const { skeletonResourceReference } = ctx;
const {
@@ -758,6 +793,7 @@ export function lineBreaking(
const paragraphConfig: IParagraphConfig = {
paragraphIndex: endIndex,
isInsideTable: _isInsideFlowTable(viewModel, endIndex),
documentCompatibilityPolicy,
paragraphStyle: resolvedParagraphStyle,
docxFallbackAnchorLeft: _getFollowingIndentedParagraphAnchorLeft(
@@ -831,28 +867,31 @@ export function lineBreaking(
segmentParagraphCache.set(endIndex, paragraphConfig);
let allPages = [curPage];
const explicitStructuralBreak = _hasExplicitStructuralBreak(shapedTextList);
const traditionalPagination = isTraditionalDocumentCompatibility(documentCompatibilityPolicy);
const explicitStructuralBreak = _hasExplicitStructuralBreak(shapedTextList);
const forcePageBreakBefore =
traditionalPagination &&
resolvedParagraphStyle.pageBreakBefore === BooleanNumber.TRUE &&
(resolvedParagraphStyle.pageBreakBefore === BooleanNumber.TRUE || tablePageBreakBefore) &&
_hasPageContent(curPage) &&
!_hasOnlyExplicitPageBoundaryMarkers(curPage);
if (forcePageBreakBefore) {
allPages.push(
createSkeletonPage(
ctx,
sectionBreakConfig,
skeletonResourceReference,
_getNextPageNumber(curPage),
BreakType.PAGE
)
const nextPage = createSkeletonPage(
ctx,
sectionBreakConfig,
skeletonResourceReference,
_getNextPageNumber(curPage),
BreakType.PAGE
);
nextPage.isExplicitPageBreak = true;
allPages.push(nextPage);
ctx.paragraphsOpenNewPage.add(endIndex);
}
let isParagraphFirstShapedText = true; // First shaped text
let renderParagraphBullet = true;
let shapedTextOffset = 0;
for (const [_index, { text, glyphs, breakPointType }] of _mergeAdjacentCustomBlockShapedTexts(shapedTextList, paragraphNonInlineSkeDrawingsByBlockId).entries()) {
let renderedPageBreakAnchorPage = curPage;
const mergedShapedTextList = _mergeAdjacentCustomBlockShapedTexts(shapedTextList, paragraphNonInlineSkeDrawingsByBlockId);
for (const [index, { text, glyphs, breakPointType }] of mergedShapedTextList.entries()) {
const textStartIndex = paragraphNode.startIndex + shapedTextOffset;
const textGlyphCount = _glyphCount(glyphs);
const textEndIndex = textStartIndex + textGlyphCount;
@@ -876,30 +915,53 @@ export function lineBreaking(
sectionBreakConfig,
paragraphConfig,
isParagraphFirstShapedText || hasOnlyFloatingCustomBlockGlyphs(glyphs, paragraphNonInlineSkeDrawingsByBlockId),
breakPointType
breakPointType,
renderParagraphBullet
);
isParagraphFirstShapedText = false;
renderParagraphBullet = false;
};
if (_endsWithToken(text, glyphs, DataStreamTreeTokenType.PAGE_BREAK)) {
pushPending();
allPages.push(
createSkeletonPage(
const currentPage = allPages[allPages.length - 1];
const isRenderedPageBreak =
traditionalPagination && _isRenderedPageBreak(viewModel, textEndIndex - 1);
const naturallyAdvancedInsideTable =
isRenderedPageBreak &&
_isInsideFlowTable(viewModel, textEndIndex - 1) &&
currentPage.isNaturalPageOverflow === true;
const alreadyAdvancedNaturally =
isRenderedPageBreak &&
(
currentPage !== renderedPageBreakAnchorPage ||
naturallyAdvancedInsideTable ||
_hasReachedRenderedPageBreak(viewModel, textEndIndex - 1, currentPage)
);
if (
!alreadyAdvancedNaturally &&
_hasPageContent(currentPage) &&
!_hasOnlyExplicitPageBoundaryMarkers(currentPage)
) {
const nextPage = createSkeletonPage(
ctx,
sectionBreakConfig,
skeletonResourceReference,
_getNextPageNumber(allPages[allPages.length - 1]),
_getNextPageNumber(currentPage),
BreakType.PAGE
)
);
);
nextPage.isExplicitPageBreak = true;
allPages.push(nextPage);
}
renderedPageBreakAnchorPage = allPages[allPages.length - 1];
paragraphNonInlineSkeDrawings.clear();
isParagraphFirstShapedText = true;
shapedTextOffset += textGlyphCount;
continue;
} else if (
_endsWithToken(text, glyphs, DataStreamTreeTokenType.COLUMN_BREAK) &&
(!isTraditionalDocumentCompatibility(documentCompatibilityPolicy) || _isMarkedDocxColumnBreak(viewModel, textEndIndex - 1))
(!isTraditionalDocumentCompatibility(documentCompatibilityPolicy) || _isMarkedColumnBreak(viewModel, textEndIndex - 1))
) {
pushPending();
// Column break mark, still within the same section
@@ -19,7 +19,7 @@ import type { ISectionBreakConfig } from '../../../../../basics/interfaces';
import type { DataStreamTreeNode } from '../../../view-model/data-stream-tree-node';
import type { DocumentViewModel } from '../../../view-model/document-view-model';
import type { ILayoutContext } from '../../tools';
import { DataStreamTreeNodeType } from '@univerjs/core';
import { BooleanNumber, DataStreamTreeNodeType } from '@univerjs/core';
import { clearFontCreateConfigCache } from '../../tools';
import { createTableSkeleton } from '../table';
import { lineAdjustment } from './line-adjustment';
@@ -36,9 +36,13 @@ export function dealWidthParagraph(
clearFontCreateConfigCache();
const { content = '', children } = paragraphNode;
let tableSkeleton = null;
let tablePageBreakBefore = false;
// Need to create table before shaping....
if (children.length === 1 && children[0].nodeType === DataStreamTreeNodeType.TABLE) {
const firstCellParagraph = children[0].children[0]?.children[0]?.children[0]?.children[0];
tablePageBreakBefore = firstCellParagraph != null &&
viewModel.getParagraph(firstCellParagraph.endIndex)?.paragraphStyle?.pageBreakBefore === BooleanNumber.TRUE;
tableSkeleton = createTableSkeleton(
ctx,
curPage,
@@ -65,7 +69,8 @@ export function dealWidthParagraph(
curPage,
paragraphNode,
sectionBreakConfig,
tableSkeleton
tableSkeleton,
tablePageBreakBefore
);
// Step 3: Line Adjustment.
@@ -26,6 +26,7 @@ import type { DataStreamTreeNode } from '../../view-model/data-stream-tree-node'
import type { DocumentViewModel } from '../../view-model/document-view-model';
import type { ILayoutContext } from '../tools';
import { BooleanNumber, TableAlignmentType, TableRowHeightRule, VerticalAlignmentType } from '@univerjs/core';
import { DocumentSkeletonPageType } from '../../../../basics';
import { getDocumentCompatibilityPolicy } from '../../document-compatibility';
import { createNullCellPage, createSkeletonCellPages } from '../model/page';
@@ -39,7 +40,7 @@ export function createTableSkeleton(
const { startIndex, endIndex, children: rowNodes } = tableNode;
const table = viewModel.getTableByStartIndex(startIndex)?.tableSource;
if (table == null) {
console.warn('Table not found when creating table skeleton');
console.warn(`Table not found when creating table skeleton at index ${startIndex}`);
return null;
}
@@ -73,7 +74,7 @@ export function createTableSkeleton(
continue;
}
const cellPageSkeleton = createSkeletonCellPages(
const cellPageSkeletons = createSkeletonCellPages(
ctx,
viewModel,
cellNode,
@@ -81,10 +82,16 @@ export function createTableSkeleton(
table,
row,
col
)[0];
);
if (cellPageSkeletons.slice(1).some((page) => page.isExplicitPageBreak === true)) {
tableSkeleton.hasPageBreak = true;
}
const cellPageSkeleton = cellPageSkeletons[0];
const { marginTop = 0, marginBottom = 0 } = cellPageSkeleton;
const pageHeight = cellPageSkeleton.height + marginTop + marginBottom;
const pageHeight = getCellPagesLayoutHeight(
cellPageSkeletons,
curPage.type === DocumentSkeletonPageType.CELL
);
cellPageSkeleton.left = left;
left += cellPageSkeleton.pageWidth;
cellPageSkeleton.parent = rowSkeleton;
@@ -95,7 +102,7 @@ export function createTableSkeleton(
if (hRule === TableRowHeightRule.AT_LEAST) {
rowHeight = Math.max(rowHeight, val.v);
} else if (hRule === TableRowHeightRule.EXACT) {
rowHeight = Math.max(rowHeight, val.v);
rowHeight = val.v;
}
// Set row height to cell page height.
@@ -153,6 +160,14 @@ export function createTableSkeleton(
return tableSkeleton;
}
function getCellPagesLayoutHeight(pages: IDocumentSkeletonPage[], includeContinuations: boolean): number {
const measuredPages = includeContinuations ? pages : pages.slice(0, 1);
return measuredPages.reduce((total, page) => {
const { marginTop = 0, marginBottom = 0 } = page;
return total + page.height + marginTop + marginBottom;
}, 0);
}
export function rollbackListCache(listLevel: Map<string, IParagraphList[][]>, table: DataStreamTreeNode) {
const { startIndex, endIndex } = table;
@@ -198,7 +213,7 @@ export function createTableSkeletons(
const table = viewModel.getTableByStartIndex(startIndex)?.tableSource;
if (table == null) {
console.warn('Table not found when creating table skeletons');
console.warn(`Table not found when creating sliced table skeletons at index ${startIndex}`);
return {
skeTables,
fromCurrentPage: false,
@@ -311,13 +326,12 @@ function dealWithTableRow(
const { trHeight, cantSplit } = rowSource;
const rowSkeletons: IDocumentSkeletonRow[] = [];
const { hRule, val } = trHeight;
const canRowSplit = cantSplit !== BooleanNumber.TRUE && trHeight.hRule === TableRowHeightRule.AUTO;
// If the remain height is less than 50 pixels, you can't fit the next line, so you can start typography directly from the second page.
const MAX_FONT_SIZE = 72;
const needOpenNewTable = cache.remainHeight <= MAX_FONT_SIZE;
const canRowSplit = cantSplit !== BooleanNumber.TRUE && trHeight.hRule !== TableRowHeightRule.EXACT;
const needOpenNewTable = cache.remainHeight <= 0;
let curTableSkeleton = getCurTableSkeleton(skeTables);
const rowHeights = [0];
const forcedPageBreakRows = new WeakSet<IDocumentSkeletonRow>();
for (const cellNode of cellNodes) {
const col = cellNodes.indexOf(cellNode);
@@ -372,6 +386,14 @@ function dealWithTableRow(
const pageIndex = cellPageSkeletons.indexOf(cellPageSkeleton);
const rowSke = rowSkeletons[pageIndex];
// A rendered page boundary inside a cell is a structural split, even when an
// ancestor cell is measured with infinite height. Propagating that boundary
// through each enclosing table keeps deeply nested DOCX tables on the same
// physical pages without persisting any format-specific layout side channel.
if (pageIndex > 0 && cellPageSkeleton.isExplicitPageBreak === true) {
forcedPageBreakRows.add(rowSke);
}
cellPageSkeleton.parent = rowSke;
rowSke.cells[col] = cellPageSkeleton;
rowHeights[pageIndex] = Math.max(rowHeights[pageIndex], cellPageHeight);
@@ -385,7 +407,7 @@ function dealWithTableRow(
if (hRule === TableRowHeightRule.AT_LEAST) {
rowHeights[rowIndex] = Math.max(rowHeights[rowIndex], val.v);
} else if (hRule === TableRowHeightRule.EXACT) {
rowHeights[rowIndex] = Math.max(rowHeights[rowIndex], val.v);
rowHeights[rowIndex] = val.v;
}
rowHeights[rowIndex] = Math.min(rowHeights[rowIndex], pageContentHeight);
@@ -417,8 +439,9 @@ function dealWithTableRow(
}
// Handle vertical alignment in cell.
const isSplitRow = rowSkeletons.length > 1;
for (const rowSkeleton of rowSkeletons) {
_verticalAlignInCell(rowSkeleton, rowSource);
_verticalAlignInCell(rowSkeleton, rowSource, isSplitRow);
}
while (rowSkeletons.length > 0) {
@@ -426,7 +449,8 @@ function dealWithTableRow(
const lastRow = curTableSkeleton.rows[curTableSkeleton.rows.length - 1];
const rowOverflowHeight = rowSkeleton.height - cache.remainHeight;
const shouldOpenNewTable =
cache.remainHeight < MAX_FONT_SIZE ||
cache.remainHeight <= 0 ||
forcedPageBreakRows.has(rowSkeleton) ||
rowOverflowHeight > documentCompatibilityPolicy.table.rowOverflowTolerance;
if (shouldOpenNewTable) {
@@ -483,12 +507,13 @@ function getLeadingRepeatHeaderRows(table: ITable, rowNodes: DataStreamTreeNode[
repeatRows.push(rowNodes[index]);
}
return repeatRows;
return repeatRows.length === rowNodes.length ? [] : repeatRows;
}
function _verticalAlignInCell(
rowSkeleton: IDocumentSkeletonRow,
rowSource: ITableRow
rowSource: ITableRow,
isSplitRow = false
) {
for (let i = 0; i < rowSource.tableCells.length; i++) {
const cellConfig = rowSource.tableCells[i];
@@ -504,6 +529,13 @@ function _verticalAlignInCell(
let marginTop = originMarginTop;
// Word applies cell vertical alignment to an unsplit row as a whole. Centering or bottom-aligning
// every continuation fragment independently creates large blank areas and clipped text.
if (isSplitRow) {
cellPageSkeleton.marginTop = originMarginTop;
continue;
}
switch (vAlign) {
case VerticalAlignmentType.TOP: {
marginTop = originMarginTop;
@@ -17,7 +17,9 @@
import type { ColumnSeparatorType, ISectionColumnProperties, LocaleService, Nullable } from '@univerjs/core';
import type {
IDocumentSkeletonCached,
IDocumentSkeletonColumn,
IDocumentSkeletonGlyph,
IDocumentSkeletonLine,
IDocumentSkeletonPage,
ISkeletonResourceReference,
} from '../../../basics/i-document-skeleton-cached';
@@ -25,9 +27,10 @@ import type { IDocsConfig, INodeInfo, INodePosition, INodeSearch } from '../../.
import type { IViewportInfo, Vector2 } from '../../../basics/vector2';
import type { DocumentViewModel } from '../view-model/document-view-model';
import type { IDocumentPaginationMetrics, ILayoutContext } from './tools';
import { DataStreamTreeTokenType, PRESET_LIST_TYPE, SectionType, Skeleton } from '@univerjs/core';
import { BooleanNumber, DataStreamTreeTokenType, PRESET_LIST_TYPE, SectionType, Skeleton } from '@univerjs/core';
import { Subject } from 'rxjs';
import {
BreakType,
DocumentSkeletonPageType,
GlyphType,
LineType,
@@ -72,6 +75,166 @@ function hasCompatiblePageGeometry(page: IDocumentSkeletonPage, config: ReturnTy
page.marginRight === marginRight;
}
function hasCompatiblePhysicalPage(page: IDocumentSkeletonPage, config: ReturnType<typeof prepareSectionBreakConfig>): boolean {
const { pageSize, pageOrient } = config;
return page.pageWidth === pageSize?.width &&
page.pageHeight === pageSize?.height &&
page.pageOrient === pageOrient;
}
function hasAvailableContinuousSectionSpace(page: IDocumentSkeletonPage): boolean {
const lastSection = page.sections.at(-1);
const contentHeight = page.pageHeight - page.marginTop - page.marginBottom;
const flowHeight = Math.max(
0,
...(lastSection?.columns ?? []).flatMap((column) =>
column.lines.map((line) => line.top + line.lineHeight)
)
);
return (lastSection?.top ?? 0) + flowHeight < contentHeight - 1e-6;
}
function hasOnlyExplicitPageBoundaryMarkers(page: IDocumentSkeletonPage): boolean {
if ((page.skeTables?.size ?? 0) > 0) {
return false;
}
return (page.sections ?? []).every((section) =>
section.columns.every((column) =>
column.lines.every((line) =>
line.divides.every((divide) =>
divide.glyphGroup.every(({ raw, streamType }) =>
raw === DataStreamTreeTokenType.PARAGRAPH ||
streamType === DataStreamTreeTokenType.PARAGRAPH ||
raw === DataStreamTreeTokenType.PAGE_BREAK ||
streamType === DataStreamTreeTokenType.PAGE_BREAK ||
raw === DataStreamTreeTokenType.SECTION_BREAK ||
streamType === DataStreamTreeTokenType.SECTION_BREAK
)
)
)
)
);
}
interface IColumnFlowLine {
line: IDocumentSkeletonLine;
gapBefore: number;
}
function collectColumnFlowLines(columns: IDocumentSkeletonColumn[]): IColumnFlowLine[] {
return columns.flatMap((column) => {
let previousBottom = 0;
return column.lines.map((line) => {
const gapBefore = Math.max(0, line.top - previousBottom);
previousBottom = line.top + line.lineHeight;
return { line, gapBefore };
});
});
}
function takeBalancedColumnLineCount(
flowLines: IColumnFlowLine[],
startIndex: number,
remainingColumnCount: number,
targetHeight: number
): number {
const maximumCount = flowLines.length - startIndex - (remainingColumnCount - 1);
let count = 0;
let height = 0;
while (count < maximumCount) {
const { line, gapBefore } = flowLines[startIndex + count];
const nextHeight = height + gapBefore + line.lineHeight;
if (count > 0 && Math.abs(targetHeight - height) <= Math.abs(targetHeight - nextHeight)) {
break;
}
height = nextHeight;
count++;
}
return Math.max(1, count);
}
function hasSameColumnGeometry(
currentColumns: ISectionColumnProperties[],
nextColumns: ISectionColumnProperties[]
): boolean {
return currentColumns.length === nextColumns.length && currentColumns.every((column, index) => {
const nextColumn = nextColumns[index];
return nextColumn != null &&
Math.abs(column.width - nextColumn.width) <= 0.01 &&
Math.abs(column.paddingEnd - nextColumn.paddingEnd) <= 0.01;
});
}
/**
* Word balances the final page of a multi-column section before a continuous
* section break. The normal page layout intentionally fills columns in flow
* order, so rebalance the final line-only fragment once its complete height is
* known. Tables, column groups, drawings, and unequal-width columns stay on
* the regular layout path because moving those blocks requires a full relayout.
*/
function balanceFinalContinuousColumnSection(page: IDocumentSkeletonPage): void {
const section = page.sections.at(-1);
if (section == null || section.columns.length < 2 || section.columns.at(-1)?.isFull) {
return;
}
const firstWidth = section.columns[0].width;
if (
section.columns.some((column) =>
Math.abs(column.width - firstWidth) > 0.01 ||
column.drawingLRIds.length > 0 ||
column.lines.some((line) => line.type !== LineType.PARAGRAPH || line.tableId !== '')
) ||
page.skeColumnGroups.size > 0 ||
page.skeDrawings.size > 0
) {
return;
}
const flowLines = collectColumnFlowLines(section.columns);
if (flowLines.length < section.columns.length) {
return;
}
let lineIndex = 0;
let remainingHeight = flowLines.reduce(
(height, { line, gapBefore }) => height + gapBefore + line.lineHeight,
0
);
for (let columnIndex = 0; columnIndex < section.columns.length; columnIndex++) {
const column = section.columns[columnIndex];
const remainingColumnCount = section.columns.length - columnIndex;
const lineCount = remainingColumnCount === 1
? flowLines.length - lineIndex
: takeBalancedColumnLineCount(
flowLines,
lineIndex,
remainingColumnCount,
remainingHeight / remainingColumnCount
);
const assignedLines = flowLines.slice(lineIndex, lineIndex + lineCount);
let columnHeight = 0;
column.lines = assignedLines.map(({ line, gapBefore }) => {
columnHeight += gapBefore;
line.top = columnHeight;
line.parent = column;
columnHeight += line.lineHeight;
return line;
});
column.height = columnHeight;
column.isFull = false;
remainingHeight -= assignedLines.reduce(
(height, { line, gapBefore }) => height + gapBefore + line.lineHeight,
0
);
lineIndex += lineCount;
}
section.height = Math.max(...section.columns.map((column) => column.height ?? 0));
}
function isTargetPageParity(pageNumber: number, sectionType: SectionType): boolean {
return sectionType === SectionType.EVEN_PAGE ? pageNumber % 2 === 0 : pageNumber % 2 === 1;
}
@@ -99,7 +262,7 @@ function mergeContinuousDuplicatePages(pages: IDocumentSkeletonPage[]) {
const previousPage = pages[index - 1];
const page = pages[index];
if (previousPage.pageNumber !== page.pageNumber) {
if (previousPage.pageNumber !== page.pageNumber || previousPage.sectionId !== page.sectionId) {
index++;
continue;
}
@@ -1292,6 +1455,7 @@ export class DocumentSkeleton extends Skeleton {
paragraphLineGapDefault = 0,
defaultTabStop = 10.5,
textStyle = {},
adjustLineHeightInTable = BooleanNumber.FALSE,
} = documentStyle;
const docsConfig: IDocsConfig = {
@@ -1305,6 +1469,7 @@ export class DocumentSkeleton extends Skeleton {
paragraphLineGapDefault,
defaultTabStop,
documentTextStyle: textStyle,
adjustLineHeightInTable,
};
const skeleton = getNullSkeleton();
@@ -1401,8 +1566,9 @@ export class DocumentSkeleton extends Skeleton {
// Loop the sections with the start section index.
for (let i = startSectionIndex, len = viewModel.getChildren().length; i < len; i++) {
const sectionNode = viewModel.getChildren()[i];
const sectionLayoutAnchor = i === startSectionIndex ? layoutAnchor : null;
const sectionBreakConfig = prepareSectionBreakConfig(ctx, i);
const { sectionType, columnProperties, columnSeparatorType, sectionTypeNext, pageNumberStart = 1 } = sectionBreakConfig;
const { sectionType, columnProperties, columnSeparatorType, sectionTypeNext, pageNumberStart = 1, evenAndOddHeaders } = sectionBreakConfig;
const explicitPageNumberStart = viewModel.getSectionBreak(sectionNode.endIndex)?.pageNumberStart;
const effectiveSectionType = getEffectiveSectionType(sectionType);
@@ -1414,7 +1580,7 @@ export class DocumentSkeleton extends Skeleton {
if (
effectiveSectionType === SectionType.NEXT_COLUMN &&
layoutAnchor == null &&
sectionLayoutAnchor == null &&
curSkeletonPage != null &&
hasCompatiblePageGeometry(curSkeletonPage, sectionBreakConfig)
) {
@@ -1426,13 +1592,20 @@ export class DocumentSkeleton extends Skeleton {
);
}
if (
const hasCompatibleContinuousPage =
effectiveSectionType === SectionType.CONTINUOUS &&
curSkeletonPage != null &&
hasCompatiblePageGeometry(curSkeletonPage, sectionBreakConfig)
) {
hasCompatiblePhysicalPage(curSkeletonPage, sectionBreakConfig);
if (hasCompatibleContinuousPage) {
updateBlockIndex(allSkeletonPages, -1, ctx.docsConfig.documentCompatibilityPolicy);
if (layoutAnchor != null && layoutAnchor >= sectionNode.startIndex && layoutAnchor <= sectionNode.endIndex) {
}
if (
hasCompatibleContinuousPage &&
curSkeletonPage != null &&
(sectionLayoutAnchor != null || hasAvailableContinuousSectionSpace(curSkeletonPage))
) {
if (sectionLayoutAnchor != null) {
this._restoreContinuousSection(curSkeletonPage, columnProperties!, columnSeparatorType!);
} else {
this._addNewSectionByContinuous(curSkeletonPage, columnProperties!, columnSeparatorType!);
@@ -1440,10 +1613,40 @@ export class DocumentSkeleton extends Skeleton {
reuseCurrentPage = true;
} else if (reuseNextColumn) {
reuseCurrentPage = true;
} else if (layoutAnchor == null || curSkeletonPage == null) {
let nextPageNumber = curSkeletonPage == null
? pageNumberStart
: explicitPageNumberStart ?? curSkeletonPage.pageNumber + 1;
} else if (sectionLayoutAnchor == null || curSkeletonPage == null) {
const reuseExplicitPageBreak =
effectiveSectionType === SectionType.NEXT_PAGE &&
curSkeletonPage?.breakType === BreakType.PAGE &&
hasOnlyExplicitPageBoundaryMarkers(curSkeletonPage);
const previousSkeletonPage = allSkeletonPages.at(-2);
const reuseOverflowedSectionBoundary =
effectiveSectionType === SectionType.NEXT_PAGE &&
curSkeletonPage?.breakType === BreakType.SECTION &&
previousSkeletonPage?.sectionId === curSkeletonPage.sectionId &&
hasOnlyExplicitPageBoundaryMarkers(curSkeletonPage);
const reuseBoundaryPage = reuseExplicitPageBreak || reuseOverflowedSectionBoundary;
let nextPageNumber = reuseBoundaryPage
? explicitPageNumberStart ?? curSkeletonPage.pageNumber
: curSkeletonPage == null
? pageNumberStart
: explicitPageNumberStart ?? curSkeletonPage.pageNumber + 1;
if (reuseBoundaryPage) {
allSkeletonPages.pop();
}
if (
curSkeletonPage != null &&
effectiveSectionType === SectionType.NEXT_PAGE &&
explicitPageNumberStart != null &&
evenAndOddHeaders === 1 &&
(allSkeletonPages.length + 1) % 2 !== explicitPageNumberStart % 2
) {
allSkeletonPages.push(createSkeletonPage(
ctx,
sectionBreakConfig,
skeletonResourceReference,
curSkeletonPage.pageNumber + 1
));
}
if (
curSkeletonPage != null &&
(effectiveSectionType === SectionType.EVEN_PAGE || effectiveSectionType === SectionType.ODD_PAGE) &&
@@ -1473,12 +1676,18 @@ export class DocumentSkeleton extends Skeleton {
sectionNode,
curSkeletonPage,
sectionBreakConfig,
layoutAnchor
sectionLayoutAnchor
);
// todo: When this section has multiple columns and the next section is of continuous type, it needs to be split by column count and recalculate lines
if (sectionTypeNext === SectionType.CONTINUOUS && columnProperties!.length > 0) {
// TODO
const nextColumnProperties = i + 1 < len
? prepareSectionBreakConfig(ctx, i + 1).columnProperties ?? []
: [];
if (
sectionTypeNext === SectionType.CONTINUOUS &&
columnProperties!.length > 0 &&
!hasSameColumnGeometry(columnProperties!, nextColumnProperties)
) {
balanceFinalContinuousColumnSection(pages.at(-1) ?? curSkeletonPage);
}
if (reuseCurrentPage) {
@@ -268,5 +268,47 @@ describe('Glyph utils test cases', () => {
expect(traditionalGlyph.width).toBeCloseTo(14.72);
expect(modernGlyph.width).toBe(16);
});
it('should calibrate SimSun CJK width only for traditional documents', () => {
vi.stubGlobal('document', {
createElement: () => ({
getContext: () => ({
font: '',
textBaseline: 'alphabetic',
measureText: () => ({
width: 16,
fontBoundingBoxAscent: 15,
fontBoundingBoxDescent: 4,
actualBoundingBoxAscent: 15,
actualBoundingBoxDescent: 4,
}),
}),
}),
});
const config = {
fontStyle: {
fontString: 'normal normal 12pt "Times New Roman", 宋体',
fontSize: 12,
originFontSize: 12,
fontFamily: '"Times New Roman", 宋体',
fontCache: 'normal normal 12pt "Times New Roman", 宋体',
},
textStyle: {},
charSpace: 0,
snapToGrid: 0,
} as any;
const traditionalGlyph = createSkeletonLetterGlyph('文', {
...config,
documentCompatibilityPolicy: getDocumentCompatibilityPolicy(DocumentFlavor.TRADITIONAL),
});
const modernGlyph = createSkeletonLetterGlyph('文', {
...config,
documentCompatibilityPolicy: getDocumentCompatibilityPolicy(DocumentFlavor.MODERN),
});
expect(traditionalGlyph.width).toBeCloseTo(15.52);
expect(modernGlyph.width).toBe(16);
});
});
});
@@ -18,15 +18,19 @@ import {
BooleanNumber,
ColumnSeparatorType,
DocumentBlockRangeType,
DocumentFlavor,
GridType,
PageOrientType,
PositionedObjectLayoutType,
} from '@univerjs/core';
import { describe, expect, it, vi } from 'vitest';
import { DocumentSkeletonPageType } from '../../../../../basics/i-document-skeleton-cached';
import { getDocumentCompatibilityPolicy } from '../../../document-compatibility';
import {
createNullCellPage,
createSkeletonCellPages,
createSkeletonPage,
expandCellPageHeightForFlowTables,
expandCellPageHeightForInlineDrawings,
} from '../page';
@@ -190,6 +194,50 @@ describe('page model', () => {
expect(page.marginBottom).toBe(40);
});
it('keeps traditional document margins when header and footer content overlap the body', () => {
dealWithSectionMock.mockImplementation((_ctx: any, _vm: any, _node: any, areaPage: any) => ({
pages: [{
...areaPage,
height: 80,
sections: [{ columns: [{ lines: [{ paragraphIndex: 0 }] }] }],
skeDrawings: new Map(),
skeTables: new Map(),
}],
}));
const skeletonResourceReference = createSkeletonResourceReference();
const ctx = {
layoutStartPointer: {},
skeletonResourceReference,
isDirty: false,
} as any;
const page = createSkeletonPage(
ctx,
{
pageNumberStart: 1,
pageSize: { width: 816, height: 1056 },
headerIds: { defaultHeaderId: 'h-default' },
footerIds: { defaultFooterId: 'f-default' },
headerTreeMap: new Map([['h-default', { getChildren: () => [{}] }]]),
footerTreeMap: new Map([['f-default', { getChildren: () => [{}] }]]),
columnProperties: [],
marginTop: 24,
marginBottom: 42,
marginHeader: 24,
marginFooter: 24,
documentCompatibilityPolicy: getDocumentCompatibilityPolicy(DocumentFlavor.TRADITIONAL),
} as any,
skeletonResourceReference,
1
);
expect(page.originMarginTop).toBe(24);
expect(page.marginTop).toBe(24);
expect(page.originMarginBottom).toBe(42);
expect(page.marginBottom).toBe(42);
});
it('does not create negative-width columns for oversized single-column section properties', () => {
const skeletonResourceReference = createSkeletonResourceReference();
const ctx = {
@@ -335,6 +383,111 @@ describe('page model', () => {
expect(updateInlineDrawingCoordsAndBorderMock).toHaveBeenCalled();
});
it('preserves document text-layout settings in a table cell section', () => {
const ctx = {
layoutStartPointer: {},
skeletonResourceReference: createSkeletonResourceReference(),
isDirty: false,
} as any;
const compatibilityPolicy = getDocumentCompatibilityPolicy(DocumentFlavor.TRADITIONAL);
const sectionBreakConfig = {
sectionId: 'traditional-section',
lists: [],
localeService: {} as any,
drawings: {},
pageSize: { width: 300, height: 200 },
headerTreeMap: new Map(),
footerTreeMap: new Map(),
documentCompatibilityPolicy: compatibilityPolicy,
documentTextStyle: { ff: '宋体', fs: 12 },
paragraphLineGapDefault: 2,
defaultTabStop: 28,
adjustLineHeightInTable: BooleanNumber.TRUE,
characterSpacingControl: 2,
useFELayout: BooleanNumber.TRUE,
spaceWidthEastAsian: BooleanNumber.TRUE,
autoHyphenation: BooleanNumber.TRUE,
consecutiveHyphenLimit: 3,
doNotHyphenateCaps: BooleanNumber.TRUE,
hyphenationZone: 12,
charSpace: 1,
linePitch: 20.8,
gridType: GridType.LINES,
renderConfig: { horizontalAlign: 1 },
} as any;
const tableConfig = {
tableId: 'traditional-table',
tableRows: [{ tableCells: [{}] }],
tableColumns: [{ size: { width: { v: 120 } } }],
} as any;
const { sectionBreakConfig: cellConfig } = createNullCellPage(
ctx,
sectionBreakConfig,
tableConfig,
0,
0
);
expect({
documentTextStyle: cellConfig.documentTextStyle,
paragraphLineGapDefault: cellConfig.paragraphLineGapDefault,
defaultTabStop: cellConfig.defaultTabStop,
adjustLineHeightInTable: cellConfig.adjustLineHeightInTable,
characterSpacingControl: cellConfig.characterSpacingControl,
useFELayout: cellConfig.useFELayout,
spaceWidthEastAsian: cellConfig.spaceWidthEastAsian,
autoHyphenation: cellConfig.autoHyphenation,
consecutiveHyphenLimit: cellConfig.consecutiveHyphenLimit,
doNotHyphenateCaps: cellConfig.doNotHyphenateCaps,
hyphenationZone: cellConfig.hyphenationZone,
charSpace: cellConfig.charSpace,
linePitch: cellConfig.linePitch,
gridType: cellConfig.gridType,
renderConfig: cellConfig.renderConfig,
}).toEqual({
documentTextStyle: { ff: '宋体', fs: 12 },
paragraphLineGapDefault: 2,
defaultTabStop: 28,
adjustLineHeightInTable: BooleanNumber.TRUE,
characterSpacingControl: 2,
useFELayout: BooleanNumber.TRUE,
spaceWidthEastAsian: BooleanNumber.TRUE,
autoHyphenation: BooleanNumber.TRUE,
consecutiveHyphenLimit: 3,
doNotHyphenateCaps: BooleanNumber.TRUE,
hyphenationZone: 12,
charSpace: 1,
linePitch: 20.8,
gridType: GridType.LINES,
renderConfig: { horizontalAlign: 1 },
});
expect(cellConfig.documentCompatibilityPolicy).toBe(compatibilityPolicy);
const { sectionBreakConfig: compactCellConfig } = createNullCellPage(
ctx,
sectionBreakConfig,
tableConfig,
0,
0,
Number.POSITIVE_INFINITY,
Number.POSITIVE_INFINITY,
false
);
expect({
documentTextStyle: compactCellConfig.documentTextStyle,
documentCompatibilityPolicy: compactCellConfig.documentCompatibilityPolicy,
adjustLineHeightInTable: compactCellConfig.adjustLineHeightInTable,
linePitch: compactCellConfig.linePitch,
}).toEqual({
documentTextStyle: sectionBreakConfig.documentTextStyle,
documentCompatibilityPolicy: compatibilityPolicy,
adjustLineHeightInTable: BooleanNumber.TRUE,
linePitch: undefined,
});
});
it('finishes dirty floating-object relayout inside the table cell segment', () => {
dealWithSectionMock.mockClear();
resetContextMock.mockClear();
@@ -424,6 +577,133 @@ describe('page model', () => {
expect(page.sections[0].columns[0].width).toBeGreaterThan(0);
});
it('keeps the outer table height constraint when a cell contains a rendered page break', () => {
let initialPageHeight = 0;
dealWithSectionMock.mockImplementation((_ctx: any, _vm: any, _node: any, areaPage: any) => {
initialPageHeight = areaPage.pageHeight;
return { pages: [createDealPage(areaPage)] };
});
const ctx = {
dataModel: {
getBody: () => ({
dataStream: '0123456789\f123456789',
renderedPageBreaks: [10],
}),
},
layoutStartPointer: {},
skeletonResourceReference: createSkeletonResourceReference(),
isDirty: false,
} as any;
const sectionBreakConfig = {
lists: [],
localeService: {} as any,
drawings: {},
pageSize: { width: 300, height: 200 },
headerTreeMap: new Map(),
footerTreeMap: new Map(),
documentCompatibilityPolicy: getDocumentCompatibilityPolicy(DocumentFlavor.TRADITIONAL),
} as any;
createSkeletonCellPages(
ctx,
{} as any,
{ startIndex: 8, endIndex: 12, children: [{}] } as any,
sectionBreakConfig,
{
tableId: 'table-1',
tableRows: [{ tableCells: [{}] }],
tableColumns: [{ size: { width: { v: 80 } } }],
} as any,
0,
0,
80,
120
);
expect(initialPageHeight).toBe(80);
});
it('uses grid columns after preceding compact column spans', () => {
const ctx = {
layoutStartPointer: {},
skeletonResourceReference: createSkeletonResourceReference(),
isDirty: false,
} as any;
const sectionBreakConfig = {
lists: [],
localeService: {} as any,
drawings: {},
pageSize: { width: 300, height: 200 },
headerTreeMap: new Map(),
footerTreeMap: new Map(),
} as any;
const tableConfig = {
tableId: 'compact-spans',
tableRows: [{ tableCells: [{ columnSpan: 2 }, { columnSpan: 2 }] }],
tableColumns: [20, 30, 40, 50].map((width) => ({ size: { width: { v: width } } })),
} as any;
const first = createNullCellPage(ctx, sectionBreakConfig, tableConfig, 0, 0);
const second = createNullCellPage(ctx, sectionBreakConfig, tableConfig, 0, 1);
expect(first.page.pageWidth).toBe(50);
expect(second.page.pageWidth).toBe(90);
});
it('keeps vertically covered grid columns when sizing following cells', () => {
const ctx = {
layoutStartPointer: {},
skeletonResourceReference: createSkeletonResourceReference(),
isDirty: false,
} as any;
const sectionBreakConfig = {
lists: [],
localeService: {} as any,
drawings: {},
pageSize: { width: 300, height: 200 },
headerTreeMap: new Map(),
footerTreeMap: new Map(),
} as any;
const tableConfig = {
tableId: 'vertical-merge',
tableRows: [
{ tableCells: [{ rowSpan: 2 }, {}, {}] },
{ tableCells: [{ rowSpan: 0, columnSpan: 0 }, {}, {}] },
],
tableColumns: [20, 80, 40].map((width) => ({ size: { width: { v: width } } })),
} as any;
const secondColumn = createNullCellPage(ctx, sectionBreakConfig, tableConfig, 1, 1);
expect(secondColumn.page.pageWidth).toBe(80);
});
it('sizes cells from their logical grid column after gridBefore', () => {
const ctx = {
layoutStartPointer: {},
skeletonResourceReference: createSkeletonResourceReference(),
isDirty: false,
} as any;
const sectionBreakConfig = {
lists: [],
localeService: {} as any,
drawings: {},
pageSize: { width: 300, height: 200 },
headerTreeMap: new Map(),
footerTreeMap: new Map(),
} as any;
const tableConfig = {
tableId: 'grid-before',
tableRows: [{ gridBefore: 1, tableCells: [{}] }],
tableColumns: [20, 80, 40].map((width) => ({ size: { width: { v: width } } })),
} as any;
const middleColumn = createNullCellPage(ctx, sectionBreakConfig, tableConfig, 0, 0);
expect(middleColumn.page.pageWidth).toBe(80);
});
it('adds trailing block range spacing to table cell height when the block range is the last cell element', () => {
dealWithSectionMock.mockImplementation((_ctx: any, _vm: any, _node: any, areaPage: any) => ({
pages: [{
@@ -476,7 +756,7 @@ describe('page model', () => {
expect(pages[0].height).toBe(48);
});
it('expands table cell height to include inline drawings', () => {
it('DOCX golden e2e expands table cell height to include inline drawings', () => {
const page = {
height: 20,
skeDrawings: new Map([
@@ -502,6 +782,28 @@ describe('page model', () => {
expect(page.height).toBe(54);
});
it('expands table cell height to include nested flow tables', () => {
const page = {
height: 20,
skeTables: new Map([
['flow-table', {
top: 12,
height: 80,
tableSource: {},
}],
['floating-table', {
top: 10,
height: 100,
tableSource: { textWrap: 1 },
}],
]),
};
expandCellPageHeightForFlowTables([page as never]);
expect(page.height).toBe(92);
});
it('does not add trailing block range spacing when content follows in the cell', () => {
dealWithSectionMock.mockImplementation((_ctx: any, _vm: any, _node: any, areaPage: any) => ({
pages: [{
@@ -556,4 +858,120 @@ describe('page model', () => {
expect(pages[0].height).toBe(20);
});
it('DOCX golden e2e locks public layout projections from the sample failure clusters', () => {
const resource = createSkeletonResourceReference();
const ctx = {
layoutStartPointer: {},
skeletonResourceReference: resource,
isDirty: false,
} as any;
const section = {
pageNumberStart: 1,
pageSize: { width: 816, height: 1056 },
marginLeft: 96,
marginRight: 96,
marginTop: 96,
marginBottom: 96,
headerTreeMap: new Map(),
footerTreeMap: new Map(),
columnProperties: [
{ width: 180, paddingEnd: 24 },
{ width: 210, paddingEnd: 36 },
{ width: 174, paddingEnd: 0 },
],
columnSeparatorType: ColumnSeparatorType.BETWEEN_EACH_COLUMN,
documentCompatibilityPolicy: getDocumentCompatibilityPolicy(DocumentFlavor.TRADITIONAL),
documentTextStyle: { ff: 'SimSun', fs: 12 },
adjustLineHeightInTable: BooleanNumber.TRUE,
linePitch: 20.8,
gridType: GridType.LINES,
} as any;
const page = createSkeletonPage(ctx, section, resource, 1);
const table = {
tableId: 'golden-table',
tableRows: [{ gridBefore: 1, tableCells: [{ columnSpan: 2 }, {}] }],
tableColumns: [20, 80, 40, 50].map((width) => ({ size: { width: { v: width } } })),
} as any;
const firstCell = createNullCellPage(ctx, section, table, 0, 0);
const secondCell = createNullCellPage(ctx, section, table, 0, 1);
const inlinePage = {
height: 20,
skeDrawings: new Map([
['inline-image', {
aTop: 6,
height: 48,
drawingOrigin: { layoutType: PositionedObjectLayoutType.INLINE },
}],
]),
};
const nestedTablePage = {
height: 20,
skeTables: new Map([
['nested-table', { top: 12, height: 80, tableSource: {} }],
]),
};
expandCellPageHeightForInlineDrawings([inlinePage as never]);
expandCellPageHeightForFlowTables([nestedTablePage as never]);
expect({
'0004-table-image-mix': {
inlineCellHeight: inlinePage.height,
nestedTableCellHeight: nestedTablePage.height,
},
'0006-long-table-grid': {
cellWidths: [firstCell.page.pageWidth, secondCell.page.pageWidth],
},
'0009-document-grid': {
adjustLineHeightInTable: firstCell.sectionBreakConfig.adjustLineHeightInTable,
fontFamily: firstCell.sectionBreakConfig.documentTextStyle?.ff,
gridType: firstCell.sectionBreakConfig.gridType,
linePitch: firstCell.sectionBreakConfig.linePitch,
},
'0026-multi-section-columns': {
columns: page.sections[0].columns.map((column) => ({ left: column.left, width: column.width })),
pageSize: [page.pageWidth, page.pageHeight],
},
}).toMatchInlineSnapshot(`
{
"0004-table-image-mix": {
"inlineCellHeight": 54,
"nestedTableCellHeight": 92,
},
"0006-long-table-grid": {
"cellWidths": [
120,
50,
],
},
"0009-document-grid": {
"adjustLineHeightInTable": 1,
"fontFamily": "SimSun",
"gridType": 1,
"linePitch": 20.8,
},
"0026-multi-section-columns": {
"columns": [
{
"left": 0,
"width": 180,
},
{
"left": 204,
"width": 210,
},
{
"left": 450,
"width": 174,
},
],
"pageSize": [
816,
1056,
],
},
}
`);
});
});
@@ -31,7 +31,11 @@ import {
isCjkLeftAlignedPunctuation,
isCjkRightAlignedPunctuation,
} from '../../../../basics/tools';
import { applyFontMetricCompatibility, getDocumentCompatibilityPolicy } from '../../document-compatibility';
import {
applyFontMetricCompatibility,
getDocumentCompatibilityPolicy,
isTraditionalDocumentCompatibility,
} from '../../document-compatibility';
import { FontCache } from '../shaping-engine/font-cache';
import { validationGrid } from '../tools';
@@ -250,13 +254,17 @@ export function _createSkeletonWordOrLetter(
let bBox = null;
let xOffset = 0;
const documentCompatibilityPolicy = config.documentCompatibilityPolicy ?? getDocumentCompatibilityPolicy();
bBox = FontCache.getTextSize(content, fontStyle);
bBox = applyFontMetricCompatibility(
content,
fontStyle,
bBox,
config.documentCompatibilityPolicy ?? getDocumentCompatibilityPolicy()
documentCompatibilityPolicy
);
if (content === DataStreamTreeTokenType.PARAGRAPH && isTraditionalDocumentCompatibility(documentCompatibilityPolicy)) {
bBox = { ...bBox, width: 0 };
}
const { width: contentWidth = 0 } = bBox;
let width = glyphWidth ?? contentWidth;
@@ -14,7 +14,7 @@
* limitations under the License.
*/
import type { IDocumentBody, ITable, Nullable } from '@univerjs/core';
import type { IDocumentBody, IParagraph, ITable, Nullable } from '@univerjs/core';
import type {
IDocumentSkeletonHeaderFooter,
IDocumentSkeletonPage,
@@ -24,11 +24,11 @@ import type { ISectionBreakConfig } from '../../../../basics/interfaces';
import type { DataStreamTreeNode } from '../../view-model/data-stream-tree-node';
import type { DocumentViewModel } from '../../view-model/document-view-model';
import type { ILayoutContext } from '../tools';
import { BooleanNumber, PageOrientType, PositionedObjectLayoutType } from '@univerjs/core';
import { BooleanNumber, GridType, PageOrientType, PositionedObjectLayoutType, SpacingRule, TableTextWrapType } from '@univerjs/core';
import { BreakType, DocumentSkeletonPageType } from '../../../../basics/i-document-skeleton-cached';
import { getDocumentCompatibilityPolicy } from '../../document-compatibility';
import { getDocumentCompatibilityPolicy, isTraditionalDocumentCompatibility } from '../../document-compatibility';
import { dealWithSection } from '../block/section';
import { resetContext, updateBlockIndex, updateInlineDrawingCoordsAndBorder } from '../tools';
import { reachesNextDocumentGridLine, resetContext, updateBlockIndex, updateInlineDrawingCoordsAndBorder } from '../tools';
import { createSkeletonSection } from './section';
function getHeaderFooterMaxHeight(pageHeight: number) {
@@ -138,8 +138,18 @@ export function createSkeletonPage(
page.originMarginTop = marginTop;
page.originMarginBottom = marginBottom;
page.marginTop = _getVerticalMargin(marginTop, header);
page.marginBottom = _getVerticalMargin(marginBottom, footer);
const documentCompatibilityPolicy =
sectionBreakConfig.documentCompatibilityPolicy ?? getDocumentCompatibilityPolicy();
if (isTraditionalDocumentCompatibility(documentCompatibilityPolicy)) {
// Word places body content at the configured page margins even when a tall
// header or footer overlaps that area. Expanding the margins here changes
// pagination and pushes page-anchored cover content into the body flow.
page.marginTop = marginTop;
page.marginBottom = marginBottom;
} else {
page.marginTop = _getVerticalMargin(marginTop, header);
page.marginBottom = _getVerticalMargin(marginBottom, footer);
}
const sections = page.sections;
const lastSection = sections[sections.length - 1];
@@ -298,9 +308,36 @@ export function createNullCellPage(
row: number,
col: number,
availableHeight: number = Number.POSITIVE_INFINITY,
maxCellPageHeight: number = Number.POSITIVE_INFINITY
maxCellPageHeight: number = Number.POSITIVE_INFINITY,
inheritDocumentLinePitch = true,
enableDocumentTableLineGrid = true
) {
const { sectionId, lists, footerTreeMap, headerTreeMap, localeService, drawings } = sectionBreakConfig;
const {
sectionId,
lists,
footerTreeMap,
headerTreeMap,
localeService,
drawings,
documentCompatibilityPolicy,
documentTextStyle,
paragraphLineGapDefault,
defaultTabStop,
adjustLineHeightInTable,
characterSpacingControl,
useFELayout,
spaceWidthEastAsian,
autoHyphenation,
consecutiveHyphenLimit,
doNotHyphenateCaps,
hyphenationZone,
charSpace,
linePitch,
gridType,
contentDirection,
textDirection,
renderConfig,
} = sectionBreakConfig;
const { skeletonResourceReference } = ctx;
const { cellMargin, tableRows, tableColumns, tableId } = tableConfig;
const cellConfig = tableRows[row].tableCells[col];
@@ -312,8 +349,9 @@ export function createNullCellPage(
bottom = { v: 5 },
} = cellConfig.margin ?? cellMargin ?? {};
const columnSpan = Math.max(1, cellConfig.columnSpan ?? 1);
const gridColumn = getTableCellGridColumn(tableConfig, row, col);
const pageWidth = tableColumns
.slice(col, col + columnSpan)
.slice(gridColumn, gridColumn + columnSpan)
.reduce((sum, column) => sum + column.size.width.v, 0);
if (start.v + end.v >= pageWidth) {
const marginWidth = start.v + end.v;
@@ -340,6 +378,24 @@ export function createNullCellPage(
marginRight: end.v,
localeService,
drawings,
documentCompatibilityPolicy,
documentTextStyle,
paragraphLineGapDefault,
defaultTabStop,
adjustLineHeightInTable: enableDocumentTableLineGrid ? adjustLineHeightInTable : undefined,
characterSpacingControl,
useFELayout,
spaceWidthEastAsian,
autoHyphenation,
consecutiveHyphenLimit,
doNotHyphenateCaps,
hyphenationZone,
charSpace,
linePitch: inheritDocumentLinePitch ? linePitch : undefined,
gridType,
contentDirection,
textDirection,
renderConfig,
};
const areaPage = createSkeletonPage(
@@ -362,6 +418,45 @@ export function createNullCellPage(
};
}
function getTableCellGridColumn(table: ITable, row: number, col: number): number {
const tableRow = table.tableRows[row];
let gridColumn = tableRow?.gridBefore ?? 0;
const cells = tableRow?.tableCells ?? [];
for (let cellIndex = 0; cellIndex < col; cellIndex++) {
const cell = cells[cellIndex];
const columnSpan = cell.columnSpan ?? 1;
if (columnSpan > 0) {
gridColumn += columnSpan;
} else if (isVerticallyCoveredGridColumn(table, row, gridColumn)) {
gridColumn += 1;
}
}
return gridColumn;
}
function isVerticallyCoveredGridColumn(table: ITable, row: number, gridColumn: number): boolean {
for (let masterRow = 0; masterRow < row; masterRow++) {
const cells = table.tableRows[masterRow]?.tableCells ?? [];
for (let masterCol = 0; masterCol < cells.length; masterCol++) {
const cell = cells[masterCol];
const rowSpan = cell.rowSpan ?? 1;
const columnSpan = cell.columnSpan ?? 1;
if (rowSpan <= 1 || columnSpan <= 0 || masterRow + rowSpan <= row) {
continue;
}
const masterGridColumn = getTableCellGridColumn(table, masterRow, masterCol);
if (gridColumn >= masterGridColumn && gridColumn < masterGridColumn + columnSpan) {
return true;
}
}
}
return false;
}
export function createSkeletonCellPages(
ctx: ILayoutContext,
viewModel: DocumentViewModel,
@@ -375,6 +470,34 @@ export function createSkeletonCellPages(
) {
// Table cell only has one section.
const sectionNode = cellNode.children[0];
const body = ctx.dataModel?.getBody?.();
const linePitch = sectionBreakConfig.linePitch ?? 0;
const usesLineGrid = sectionBreakConfig.gridType === GridType.LINES || sectionBreakConfig.gridType === GridType.LINES_AND_CHARS;
const documentCompatibilityPolicy = sectionBreakConfig.documentCompatibilityPolicy ?? getDocumentCompatibilityPolicy();
const isTraditionalLineGrid = isTraditionalDocumentCompatibility(documentCompatibilityPolicy) &&
usesLineGrid;
const usesNextDocumentGridLine = (paragraph: IParagraph) => {
const paragraphStyle = paragraph.paragraphStyle;
const lineSpacing = paragraphStyle?.lineSpacing;
return lineSpacing != null &&
paragraphStyle?.spacingRule === SpacingRule.AUTO &&
paragraphStyle.snapToGrid !== BooleanNumber.FALSE &&
reachesNextDocumentGridLine(lineSpacing, paragraphStyle.spaceBelow?.v ?? 0, linePitch);
};
const inheritDocumentLinePitch = isTraditionalLineGrid &&
body?.paragraphs?.some((paragraph) => {
if (paragraph.startIndex <= cellNode.startIndex || paragraph.startIndex >= cellNode.endIndex) {
return false;
}
return usesNextDocumentGridLine(paragraph);
}) === true;
const enableDocumentTableLineGrid = isTraditionalLineGrid &&
body?.tables?.some((table) => body.paragraphs?.some(
(paragraph) => paragraph.startIndex > table.startIndex &&
paragraph.startIndex < table.endIndex &&
usesNextDocumentGridLine(paragraph)
)) === true;
const { page: areaPage, sectionBreakConfig: cellSectionBreakConfig } = createNullCellPage(
ctx,
@@ -383,7 +506,9 @@ export function createSkeletonCellPages(
row,
col,
availableHeight,
maxCellPageHeight
maxCellPageHeight,
inheritDocumentLinePitch,
enableDocumentTableLineGrid
);
const segmentId = tableConfig.tableId;
@@ -430,14 +555,61 @@ export function createSkeletonCellPages(
sectionBreakConfig.documentCompatibilityPolicy ?? getDocumentCompatibilityPolicy()
);
applyTrailingBlockRangeSpaceBelow(pages, ctx.dataModel?.getBody?.(), cellNode.endIndex);
applyTrailingBlockRangeSpaceBelow(pages, body, cellNode.endIndex);
applyTrailingCellParagraphSpaceBelow(pages, body, cellNode.endIndex, cellSectionBreakConfig);
updateInlineDrawingCoordsAndBorder(ctx, pages);
expandCellPageHeightForInlineDrawings(pages);
expandCellPageHeightForFlowTables(pages);
return pages;
}
function applyTrailingCellParagraphSpaceBelow(
pages: IDocumentSkeletonPage[],
body: Nullable<IDocumentBody>,
containerEndIndex: number,
sectionBreakConfig: ISectionBreakConfig
) {
const page = pages[pages.length - 1];
const lastSection = page?.sections[page.sections.length - 1];
const lastColumn = lastSection?.columns[lastSection.columns.length - 1];
const lastLine = lastColumn?.lines[lastColumn.lines.length - 1];
if (!page || !lastLine) {
return;
}
const paragraphIndex = lastLine.paragraphIndex;
const hasLaterParagraph = body?.paragraphs?.some(
(paragraph) => paragraph.startIndex > paragraphIndex && paragraph.startIndex < containerEndIndex
);
const isBlockRangeParagraph = body?.blockRanges?.some(
(range) => range.startIndex < paragraphIndex && paragraphIndex < range.endIndex
);
if (hasLaterParagraph || isBlockRangeParagraph) {
return;
}
const paragraphStyle = body?.paragraphs?.find((paragraph) => paragraph.startIndex === paragraphIndex)?.paragraphStyle;
const lineSpacing = paragraphStyle?.lineSpacing;
const spaceBelow = Math.max(0, lastLine.spaceBelowApply ?? 0);
const linePitch = sectionBreakConfig.linePitch ?? 0;
const usesLineGrid = sectionBreakConfig.gridType === GridType.LINES || sectionBreakConfig.gridType === GridType.LINES_AND_CHARS;
if (
lineSpacing == null ||
paragraphStyle?.spacingRule !== SpacingRule.AUTO ||
paragraphStyle.snapToGrid === BooleanNumber.FALSE ||
!usesLineGrid ||
linePitch <= 0 ||
!reachesNextDocumentGridLine(lineSpacing, spaceBelow, linePitch) ||
!isTraditionalDocumentCompatibility(sectionBreakConfig.documentCompatibilityPolicy!)
) {
return;
}
page.height += spaceBelow;
}
export function expandCellPageHeightForInlineDrawings(pages: IDocumentSkeletonPage[]) {
for (const page of pages) {
page.skeDrawings?.forEach((drawing) => {
@@ -453,6 +625,19 @@ export function expandCellPageHeightForInlineDrawings(pages: IDocumentSkeletonPa
}
}
export function expandCellPageHeightForFlowTables(pages: IDocumentSkeletonPage[]) {
for (const page of pages) {
page.skeTables?.forEach((table) => {
const textWrap = table.tableSource.textWrap ?? TableTextWrapType.NONE;
if (textWrap !== TableTextWrapType.NONE) {
return;
}
page.height = Math.max(page.height, table.top + table.height);
});
}
}
export function applyTrailingBlockRangeSpaceBelow(pages: IDocumentSkeletonPage[], body: Nullable<IDocumentBody>, containerEndIndex: number) {
const blockRanges = body?.blockRanges;
const trailingBlockRangeSpace = 28;
@@ -64,6 +64,36 @@ describe('font cache', () => {
expect(FontCache.clearFontMeasureCache('12px Arial')).toBe(true);
});
it('calculates the line-height metric without a DOM', () => {
const browserDocument = globalThis.document;
(FontCache as unknown as { _context: CanvasRenderingContext2D })._context = {
font: '',
textBaseline: 'alphabetic',
measureText: vi.fn(() => ({
width: 12,
fontBoundingBoxAscent: 8,
fontBoundingBoxDescent: 3,
actualBoundingBoxAscent: 8,
actualBoundingBoxDescent: 3,
})),
} as unknown as CanvasRenderingContext2D;
try {
vi.stubGlobal('document', undefined);
const result = FontCache.getTextSize('A', {
fontString: '12px Arial',
fontSize: 12,
originFontSize: 12,
fontFamily: 'Arial',
fontCache: '12px Arial',
});
expect(result.normalLineHeight).toBe(11);
} finally {
vi.stubGlobal('document', browserDocument);
}
});
it('auto-cleans overflow cache and computes baseline offsets', () => {
const cache = new Map<string, any>();
for (let i = 0; i < 10; i++) {
@@ -138,6 +168,7 @@ describe('font cache', () => {
expect(byFont.width).toBeGreaterThan(0);
expect(byFont.ba).toBeCloseTo(9.6);
expect(byFont.abd).toBeCloseTo(1.8);
expect(byFont.normalLineHeight).toBeCloseTo(12);
(FontCache as any)._context = {
font: '',
@@ -199,7 +199,10 @@ export class FontCache {
bBox = this._calculateBoundingBoxByMeasureText(measureText, fontStyle);
}
return bBox;
return {
...bBox,
normalLineHeight: bBox.ba + bBox.bd,
};
}
/**
@@ -55,6 +55,7 @@ import {
BooleanNumber,
ColumnSeparatorType,
DataStreamTreeTokenType,
DEFAULT_STYLES,
DocumentFlavor,
GridType,
HorizontalAlign,
@@ -305,11 +306,26 @@ export function validationGrid(gridType = GridType.LINES, snapToGrid = BooleanNu
);
}
export function reachesNextDocumentGridLine(lineSpacing: number, spaceBelow: number, linePitch: number) {
return linePitch > 0 &&
lineSpacing * linePitch + Math.max(0, spaceBelow) >=
(Math.floor(lineSpacing + 1e-6) + 1) * linePitch - 1e-6;
}
export function getLineHeightConfig(sectionBreakConfig: ISectionBreakConfig, paragraphConfig: IParagraphConfig) {
const { paragraphStyle = {}, useWordStyleLineHeight = false } = paragraphConfig;
const { linePitch = 15.6, gridType = GridType.LINES, paragraphLineGapDefault = 0 } = sectionBreakConfig;
const hasDocumentGrid = gridType === GridType.LINES_AND_CHARS || gridType === GridType.SNAP_TO_CHARS;
const defaultSnapToGrid = useWordStyleLineHeight && !hasDocumentGrid ? BooleanNumber.FALSE : BooleanNumber.TRUE;
const { paragraphStyle = {}, useWordStyleLineHeight = false, isInsideTable = false } = paragraphConfig;
const {
linePitch = 15.6,
paragraphLineGapDefault = 0,
adjustLineHeightInTable = BooleanNumber.FALSE,
} = sectionBreakConfig;
const gridType = sectionBreakConfig.gridType ?? (useWordStyleLineHeight ? GridType.DEFAULT : GridType.LINES);
const hasLineGrid = gridType === GridType.LINES || gridType === GridType.LINES_AND_CHARS;
const defaultSnapToGrid = useWordStyleLineHeight && (
!hasLineGrid || (isInsideTable && adjustLineHeightInTable !== BooleanNumber.TRUE)
)
? BooleanNumber.FALSE
: BooleanNumber.TRUE;
const { lineSpacing = 0, spacingRule = SpacingRule.AUTO, snapToGrid = defaultSnapToGrid } = paragraphStyle;
// Flavored docs use Word-style single spacing by default.
@@ -1474,15 +1490,15 @@ export function getFontCreateConfig(
const customRange = viewModel.getCustomRange(index + startIndex);
const showCustomRange = customRange && (customRange.show !== false);
const customRangeStyle = showCustomRange ? getCustomRangeStyle(customRange) : null;
const hasAddonStyle = showCustomRange || showCustomDecoration || !!bullet || paragraphStyle?.namedStyleType;
const hasAddonStyle = showCustomRange || showCustomDecoration || !!bullet || paragraphStyle?.namedStyleType || paragraphStyle?.textStyle != null;
const { st, ed } = textRun;
let { ts: textStyle = {} } = textRun;
let textStyle: ITextStyle = textRun.ts ?? {};
const cache = fontCreateConfigCache.getValue(st, ed);
if (cache && !hasAddonStyle && originTextRun) {
return cache;
}
const { snapToGrid = BooleanNumber.TRUE, namedStyleType } = paragraphStyle;
const { snapToGrid = BooleanNumber.TRUE, namedStyleType, textStyle: paragraphTextStyle } = paragraphStyle;
const bulletTextStyle = bullet ? getBulletParagraphTextStyle(bullet, viewModel) : null;
// Apply named style if it exists
const namedStyle = namedStyleType ? NAMED_STYLE_MAP[namedStyleType] : null;
@@ -1490,13 +1506,20 @@ export function getFontCreateConfig(
textStyle = {
...documentTextStyle,
...namedStyle,
...paragraphTextStyle,
...textStyle,
...customDecorationStyle,
...customRangeStyle,
...bulletTextStyle,
};
const fontStyle = getFontStyleString(textStyle);
const eastAsiaFontFamily = textStyle.eastAsiaFontFamily?.trim();
const fontStyle = getFontStyleString(eastAsiaFontFamily
? {
...textStyle,
ff: `${textStyle.ff || DEFAULT_STYLES.ff}, ${eastAsiaFontFamily}`,
}
: textStyle);
const mixTextStyle: ITextStyle = {
...documentTextStyle,
@@ -1736,7 +1759,8 @@ export function prepareSectionBreakConfig(ctx: ILayoutContext, nodeIndex: number
const sectionNode = viewModel.getChildren()[nodeIndex];
let { documentStyle } = dataModel;
const { documentFlavor } = documentStyle;
let sectionBreak = viewModel.getSectionBreak(sectionNode.endIndex) || DEFAULT_SECTION_BREAK;
const explicitSectionBreak = viewModel.getSectionBreak(sectionNode.endIndex);
let sectionBreak = explicitSectionBreak || DEFAULT_SECTION_BREAK;
const sectionBreaks = viewModel.getChildren().map((node) => viewModel.getSectionBreak(node.endIndex) || DEFAULT_SECTION_BREAK);
sectionBreak = {
...sectionBreak,
@@ -1789,11 +1813,14 @@ export function prepareSectionBreakConfig(ctx: ILayoutContext, nodeIndex: number
wrapStrategy: WrapStrategy.UNSPECIFIED,
},
} = documentStyle;
const globalCharSpace = 0;
const globalLinePitch = 15.6;
const globalGridType = documentFlavor === DocumentFlavor.TRADITIONAL ? GridType.DEFAULT : GridType.LINES;
const {
sectionId,
charSpace = 0, // charSpace
linePitch = 15.6, // linePitch pt
gridType = GridType.LINES, // gridType
charSpace = globalCharSpace, // charSpace
linePitch = globalLinePitch, // linePitch pt
gridType = globalGridType, // gridType
pageNumberStart = global_pageNumberStart,
pageSize = global_pageSize,
@@ -1811,7 +1838,9 @@ export function prepareSectionBreakConfig(ctx: ILayoutContext, nodeIndex: number
evenPageFooterId = global_evenPageFooterId,
firstPageHeaderId = global_firstPageHeaderId,
firstPageFooterId = global_firstPageFooterId,
useFirstPageHeaderFooter = global_useFirstPageHeaderFooter,
useFirstPageHeaderFooter = documentFlavor === DocumentFlavor.TRADITIONAL && explicitSectionBreak
? BooleanNumber.FALSE
: global_useFirstPageHeaderFooter,
evenAndOddHeaders = global_evenAndOddHeaders,
columnProperties = [],
@@ -145,6 +145,39 @@ describe('DocumentViewModel', () => {
expect(paragraphWithCustomBlock?.blocks.length).toBe(1);
});
it('keeps outer-cell paragraphs outside a nested table cell', () => {
const T = DataStreamTreeTokenType;
const nestedTable = `${T.TABLE_START}${T.TABLE_ROW_START}${T.TABLE_CELL_START}Inner${T.PARAGRAPH}${T.SECTION_BREAK}${T.TABLE_CELL_END}${T.TABLE_ROW_END}${T.TABLE_END}`;
const dataStream = `${T.TABLE_START}${T.TABLE_ROW_START}${T.TABLE_CELL_START}Before${T.PARAGRAPH}${nestedTable}${T.PARAGRAPH}After${T.PARAGRAPH}${T.SECTION_BREAK}${T.TABLE_CELL_END}${T.TABLE_ROW_END}${T.TABLE_END}${T.PARAGRAPH}${T.SECTION_BREAK}`;
const outerStart = 0;
const innerStart = dataStream.indexOf(T.TABLE_START, 1);
const innerEnd = dataStream.indexOf(T.TABLE_END, innerStart) + 1;
const outerEnd = dataStream.lastIndexOf(T.TABLE_END) + 1;
const { sectionList, tableNodeCache } = parseDataStreamToTree(dataStream, [
{ tableId: 'outer', startIndex: outerStart, endIndex: outerEnd } as any,
{ tableId: 'inner', startIndex: innerStart, endIndex: innerEnd } as any,
]);
const outerTable = findFirstNodeByType(sectionList[0], DataStreamTreeNodeType.TABLE)!;
const outerCell = outerTable.children[0].children[0];
const outerParagraphs = outerCell.children[0].children;
const nestedTableNode = findFirstNodeByType(outerCell, DataStreamTreeNodeType.TABLE)!;
const nestedParagraphs = nestedTableNode.children[0].children[0].children[0].children;
expect(outerParagraphs.map((paragraph: DataStreamTreeNode) => paragraph.content)).toEqual([
`Before${T.PARAGRAPH}`,
T.PARAGRAPH,
`After${T.PARAGRAPH}${T.SECTION_BREAK}`,
]);
expect(nestedParagraphs.map((paragraph: DataStreamTreeNode) => paragraph.content)).toEqual([
`Inner${T.PARAGRAPH}${T.SECTION_BREAK}`,
]);
expect(outerTable.startIndex).toBe(outerStart);
expect(nestedTableNode.startIndex).toBe(innerStart);
expect(tableNodeCache.get('outer')?.table).toBe(outerTable);
expect(tableNodeCache.get('inner')?.table).toBe(nestedTableNode);
});
it('should ignore block range tokens while preserving inner paragraphs', () => {
const ds = [
DataStreamTreeTokenType.BLOCK_START,
@@ -80,8 +80,9 @@ export function parseDataStreamToTree(dataStream: string, tables?: ICustomTable[
const tableNodeCache: Map<string, ITableNodeCache> = new Map();
// Only use to cache the outer paragraphs.
const paragraphList: DataStreamTreeNode[] = [];
// Use to cache paragraphs in cell.
const cellParagraphList: DataStreamTreeNode[] = [];
// Each open table cell owns its paragraphs. A single shared list makes an
// inner table consume the paragraphs that precede it in the outer cell.
const cellParagraphLists: DataStreamTreeNode[][] = [];
const tableList: ITableCache[] = [];
const tableRowList: DataStreamTreeNode[] = [];
const tableCellList: DataStreamTreeNode[] = [];
@@ -93,7 +94,7 @@ export function parseDataStreamToTree(dataStream: string, tables?: ICustomTable[
const getParagraphList = () => {
if (tableCellList.length > 0) {
return cellParagraphList;
return cellParagraphLists[cellParagraphLists.length - 1];
}
if (columnGroupList.length > 0) {
@@ -120,11 +121,13 @@ export function parseDataStreamToTree(dataStream: string, tables?: ICustomTable[
content += DataStreamTreeTokenType.PARAGRAPH;
const paragraphNode = DataStreamTreeNode.create(DataStreamTreeNodeType.PARAGRAPH, content);
let wrappedTableStartIndex: number | undefined;
const lastTableCache = tableList[tableList.length - 1];
if (lastTableCache && lastTableCache.isFinished) {
// Paragraph Node will only has one table node.
batchParent(paragraphNode, [lastTableCache.table], DataStreamTreeNodeType.PARAGRAPH);
wrappedTableStartIndex = lastTableCache.table.startIndex;
if (tables) {
const table = tables.find((table) => table.startIndex === lastTableCache.table.startIndex && table.endIndex === lastTableCache.table.endIndex + 1);
@@ -137,13 +140,13 @@ export function parseDataStreamToTree(dataStream: string, tables?: ICustomTable[
}
// Paragraph start and end index is from the first char of the paragraph to the last char of the paragraph. not include the Table content.
paragraphNode.setIndexRange(i - content.length + 1, i);
paragraphNode.setIndexRange(wrappedTableStartIndex ?? i - content.length + 1, i);
paragraphNode.addBlocks(currentBlocks);
currentBlocks.length = 0;
content = '';
if (tableCellList.length > 0) {
cellParagraphList.push(paragraphNode);
cellParagraphLists[cellParagraphLists.length - 1].push(paragraphNode);
} else if (columnGroupList.length > 0) {
columnParagraphList.push(paragraphNode);
} else {
@@ -152,7 +155,7 @@ export function parseDataStreamToTree(dataStream: string, tables?: ICustomTable[
} else if (char === DataStreamTreeTokenType.SECTION_BREAK) {
const sectionNode = DataStreamTreeNode.create(DataStreamTreeNodeType.SECTION_BREAK);
const tempParagraphList = tableCellList.length > 0
? cellParagraphList
? cellParagraphLists[cellParagraphLists.length - 1]
: columnGroupList.length > 0
? columnParagraphList
: paragraphList;
@@ -212,6 +215,7 @@ export function parseDataStreamToTree(dataStream: string, tables?: ICustomTable[
const cellNode = DataStreamTreeNode.create(DataStreamTreeNodeType.TABLE_CELL);
tableCellList.push(cellNode);
cellParagraphLists.push([]);
} else if (char === DataStreamTreeTokenType.TABLE_END) {
const lastTable = tableList[tableList.length - 1];
lastTable.isFinished = true;
@@ -223,6 +227,7 @@ export function parseDataStreamToTree(dataStream: string, tables?: ICustomTable[
batchParent(lastTableCache.table, [rowNode!], DataStreamTreeNodeType.TABLE);
} else if (char === DataStreamTreeTokenType.TABLE_CELL_END) {
const cellNode = tableCellList.pop();
cellParagraphLists.pop();
const lastRow = tableRowList[tableRowList.length - 1];