mirror of
https://github.com/dream-num/univer.git
synced 2026-08-28 23:01:30 +08:00
fix(engine-render): improve traditional document layout fidelity (#7461)
This commit is contained in:
@@ -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 style,interaction
|
||||
|
||||
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#nestinglevel,ms 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 = 36pt,this 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',
|
||||
|
||||
+1
-1
@@ -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 = '';
|
||||
}
|
||||
|
||||
+1001
-10
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 = {
|
||||
|
||||
+52
-1
@@ -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);
|
||||
|
||||
+596
-12
@@ -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], {
|
||||
|
||||
+24
@@ -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');
|
||||
|
||||
|
||||
+158
-25
@@ -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
|
||||
|
||||
+7
-2
@@ -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;
|
||||
|
||||
+31
@@ -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 = [],
|
||||
|
||||
+33
@@ -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];
|
||||
|
||||
|
||||
Reference in New Issue
Block a user