feat(docs): support paginated footnotes and endnotes (#7645)

This commit is contained in:
Univer
2026-09-09 00:55:31 +08:00
committed by GitHub
parent 6d18913d07
commit 319f24d2d6
97 changed files with 6132 additions and 664 deletions
@@ -0,0 +1,119 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { IDocumentBody } from '../../../types/interfaces/i-document-data';
import { describe, expect, it } from 'vitest';
import { CustomRangeType, DocStyleType } from '../../../types/interfaces/i-document-data';
import { DocumentDataModel } from '../document-data-model';
import { JSONX } from '../json-x/json-x';
import { validateDocumentStructure } from '../text-x/structure-validator';
import { TextX } from '../text-x/text-x';
import { getRichTextEditPath } from '../text-x/utils';
function body(text: string): IDocumentBody {
return {
dataStream: `${text}\r\n`,
customBlocks: [],
customRanges: [],
customDecorations: [],
paragraphs: [{ startIndex: text.length, paragraphId: 'paragraph' }],
sectionBreaks: [{ startIndex: text.length + 1, sectionId: 'section' }],
};
}
function createDocument(): DocumentDataModel {
return new DocumentDataModel({
id: 'doc',
body: {
...body('A\uFFFCB'),
customRanges: [{ startIndex: 1, endIndex: 1, rangeId: 'ref', rangeType: CustomRangeType.FOOTNOTE, wholeEntity: true, properties: { noteId: 'note' } }],
},
notes: { note: { type: 'footnote' as const, noteId: 'note', body: body('Explanation') } },
noteSettings: { footnote: { position: 'pageBottom', restart: 'eachSect', numberFormat: 'lowerRoman', startNumber: 3 } },
});
}
describe('document footnote segments', () => {
it('inherits document styles and refreshes them without persisting duplicate styles in each note', () => {
const document = createDocument();
const jsonX = JSONX.getInstance();
try {
document.apply(jsonX.insertOp(['styles'], { FootnoteText: { name: 'Footnote Text', type: DocStyleType.paragraph, textStyle: { fs: 10 } } }));
const note = document.noteModelMap.get('note');
expect(note?.getSnapshot().styles?.FootnoteText.textStyle?.fs).toBe(10);
document.apply(jsonX.replaceOp(['styles', 'FootnoteText', 'textStyle', 'fs'], 10, 12));
expect(document.noteModelMap.get('note')).not.toBe(note);
expect(document.noteModelMap.get('note')?.getSnapshot().styles?.FootnoteText.textStyle?.fs).toBe(12);
document.updateDocumentStyle({ textStyle: { fs: 14 }, marginLeft: 40 });
expect(document.noteModelMap.get('note')?.getDocumentStyle()).toMatchObject({
textStyle: { fs: 14 },
marginLeft: 40,
});
expect(Object.keys(document.getSnapshot().notes!.note)).toEqual(['type', 'noteId', 'body']);
} finally {
document.dispose();
}
});
it('edits note text through the rich-text path and restores it through undo', () => {
const document = createDocument();
const before = structuredClone(document.getSnapshot());
const path = getRichTextEditPath(document, 'note');
const edit = JSONX.getInstance().editOp(new TextX().insert(4, { dataStream: 'New ' }).serialize(), path);
const undo = JSONX.invertWithDoc(edit, before);
document.apply(edit);
expect(document.getSelfOrHeaderFooterModel('note')?.getBody()?.dataStream).toBe('New Explanation\r\n');
expect(document.getBody()).toEqual(before.body);
document.apply(undo);
expect(document.getSnapshot()).toEqual(before);
expect(document.getSelfOrHeaderFooterModel('note')?.getBody()?.dataStream).toBe('Explanation\r\n');
document.dispose();
});
it('preserves the note identity when text before its reference is edited', () => {
const document = createDocument();
const edit = JSONX.getInstance().editOp(new TextX().insert(2, { dataStream: '前文' }).serialize(), ['body']);
document.apply(edit);
expect(document.getBody()?.customRanges?.[0]).toMatchObject({ startIndex: 3, endIndex: 3, properties: { noteId: 'note' } });
const restored = new DocumentDataModel(document.getSnapshot());
expect(restored.getSelfOrHeaderFooterModel('note')?.getBody()).toEqual(document.getSnapshot().notes?.note.body);
expect(restored.getSnapshot().noteSettings?.footnote).toEqual(document.getSnapshot().noteSettings?.footnote);
expect(validateDocumentStructure(restored.getSnapshot())).toEqual([]);
restored.dispose();
document.dispose();
});
it('keeps unrelated note models when one note is edited', () => {
const document = createDocument();
const jsonX = JSONX.getInstance();
document.apply(jsonX.insertOp(['notes', 'other'], { type: 'footnote' as const, noteId: 'other', body: body('Other explanation') }));
const other = document.noteModelMap.get('other');
const edit = jsonX.editOp(new TextX().insert(4, { dataStream: 'New ' }).serialize(), ['notes', 'note', 'body']);
document.apply(edit);
expect(document.noteModelMap.get('other')).toBe(other);
expect(document.noteModelMap.get('note')?.getBody()?.dataStream).toBe('New Explanation\r\n');
document.dispose();
});
it('removes a disposed note segment from the model after a persisted deletion', () => {
const document = createDocument();
const edit = JSONX.getInstance().removeOp(['notes', 'note'], document.getSnapshot().notes?.note);
document.apply(edit);
expect(document.getSelfOrHeaderFooterModel('note')).toBeNull();
expect(validateDocumentStructure(document.getSnapshot()).map((issue) => issue.code)).toContain('missing-note');
document.dispose();
});
});
@@ -36,7 +36,7 @@ import { Tools } from '../../shared/tools';
import { createSectionId } from '../section-break-id';
import { calculateDocumentStatistics } from './document-statistics';
import { getEmptySnapshot } from './empty-snapshot';
import { JSONX } from './json-x/json-x';
import { JSON1, JSONX } from './json-x/json-x';
import { PRESET_LIST_TYPE } from './preset-list-type';
import { getPlainText } from './text-x/build-utils/parse';
import { getBodySlice, SliceBodyType } from './text-x/utils';
@@ -337,6 +337,7 @@ export class DocumentDataModel extends DocumentDataModelSimple {
headerModelMap: Map<string, DocumentDataModel> = new Map();
footerModelMap: Map<string, DocumentDataModel> = new Map();
noteModelMap: Map<string, DocumentDataModel> = new Map();
change$ = new BehaviorSubject<number>(0);
constructor(snapshot: Partial<IDocumentData>) {
@@ -359,6 +360,8 @@ export class DocumentDataModel extends DocumentDataModelSimple {
this.footerModelMap.forEach((footer) => {
footer.dispose();
});
this.noteModelMap.forEach((note) => note.dispose());
this.noteModelMap.clear();
this._name$.complete();
}
@@ -408,6 +411,10 @@ export class DocumentDataModel extends DocumentDataModelSimple {
return this.footerModelMap.get(segmentId)!;
}
if (this.noteModelMap.has(segmentId)) {
return this.noteModelMap.get(segmentId)!;
}
return null;
}
@@ -420,8 +427,24 @@ export class DocumentDataModel extends DocumentDataModelSimple {
return;
}
const previousNotes = this.snapshot.notes;
const changedNotes = new Set<string>();
let changedInheritedNoteStyles = false;
const cursor = JSON1.type.readCursor(actions);
cursor.traverse(null, () => {
const path = cursor.getPath();
if (path[0] === 'notes' && typeof path[1] === 'string') {
changedNotes.add(path[1]);
}
if (path[0] === 'styles' || path[0] === 'documentStyle') {
changedInheritedNoteStyles = true;
}
});
this.snapshot = JSONX.apply(this.snapshot, actions) as unknown as IDocumentData;
this._markMutation();
if (changedInheritedNoteStyles || previousNotes !== this.snapshot.notes || changedNotes.size > 0) {
this._initializeNoteModels(changedInheritedNoteStyles ? undefined : previousNotes, changedNotes);
}
// FIXME: @JOCS, ANY better solution to find action that create or delete header/footer?
if (actions?.some((a) => Array.isArray(a) && (a?.[0] === 'headers' || a?.[0] === 'footers'))) {
@@ -445,6 +468,7 @@ export class DocumentDataModel extends DocumentDataModelSimple {
}
private _initializeHeaderFooterModel() {
this._initializeNoteModels();
const { headers, footers } = this.getSnapshot();
if (headers) {
@@ -464,6 +488,32 @@ export class DocumentDataModel extends DocumentDataModelSimple {
}
}
private _initializeNoteModels(previousNotes?: IDocumentData['notes'], changedNotes?: ReadonlySet<string>): void {
for (const [id, model] of this.noteModelMap) {
if (changedNotes?.has(id) || previousNotes?.[id] !== this.snapshot.notes?.[id] || !previousNotes?.[id]) {
model.dispose();
this.noteModelMap.delete(id);
}
}
for (const [id, note] of Object.entries(this.snapshot.notes ?? {})) {
if (this.noteModelMap.has(id)) {
continue;
}
const model = new DocumentDataModel({
...note,
id: this.getUnitId(),
documentStyle: this.snapshot.documentStyle,
styles: this.snapshot.styles,
});
this.noteModelMap.set(id, model);
}
}
override updateDocumentStyle(config: IDocumentStyle) {
super.updateDocumentStyle(config);
this._initializeNoteModels();
}
override updateDocumentId(unitId: string) {
super.updateDocumentId(unitId);
@@ -46,6 +46,9 @@ export function getEmptySnapshot(
drawingsOrder: [],
headers: {},
footers: {},
// Stable containers let concurrent first-note mutations address distinct child IDs.
notes: {},
noteSettings: {},
body: {
dataStream: '\r\n',
textRuns: [],
@@ -0,0 +1,83 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { IDocumentBody, IDocumentNote } from '../../../../types/interfaces/i-document-data';
import { describe, expect, it } from 'vitest';
import { CustomRangeType } from '../../../../types/interfaces/i-document-data';
import { validateDocumentStructure } from '../structure-validator';
function noteBody(text: string): IDocumentBody {
return {
dataStream: `${text}\r\n`,
paragraphs: [{ paragraphId: 'paragraph', startIndex: text.length }],
sectionBreaks: [{ sectionId: 'section', startIndex: text.length + 1 }],
};
}
describe('validateDocumentStructure notes', () => {
it.each(['footnote', 'endnote'] as const)('rejects independent page setup in a %s while accepting a normal rich note body', (type) => {
const note: IDocumentNote = { type, noteId: 'note', body: noteBody('Explanation') };
note.body.textRuns = [{ st: 0, ed: 3, ts: { bl: 1 } }];
expect(validateDocumentStructure({ notes: { note } })).toEqual([]);
note.body.sectionBreaks![0].marginTop = 50;
expect(validateDocumentStructure({ notes: { note } }).map((issue) => issue.code)).toEqual(['invalid-note-body']);
note.body = {
dataStream: 'A\r\nB\r\n',
paragraphs: [{ paragraphId: 'a', startIndex: 1 }, { paragraphId: 'b', startIndex: 4 }],
sectionBreaks: [{ sectionId: 'a', startIndex: 2 }, { sectionId: 'b', startIndex: 5 }],
};
expect(validateDocumentStructure({ notes: { note } }).map((issue) => issue.code)).toContain('invalid-note-body');
});
it('rejects duplicate references and mismatched segment identities', () => {
const body = noteBody('\uFFFC\uFFFC');
body.customRanges = [0, 1].map((index) => ({
rangeType: CustomRangeType.FOOTNOTE,
rangeId: `reference-${index}`,
startIndex: index,
endIndex: index,
wholeEntity: true,
properties: { noteId: 'note' },
}));
const notes = { note: { type: 'footnote' as const, noteId: 'note', body: noteBody('Explanation') } };
expect(validateDocumentStructure({ body, notes }).map((issue) => issue.code)).toEqual(['duplicate-note-reference']);
notes.note.noteId = 'other';
expect(validateDocumentStructure({ notes }).map((issue) => issue.code)).toEqual(['invalid-note-id']);
});
it('rejects nested references even when the referenced note exists', () => {
const body = noteBody('\uFFFC');
body.customRanges = [{ rangeType: CustomRangeType.FOOTNOTE, rangeId: 'nested', startIndex: 0, endIndex: 0, wholeEntity: true, properties: { noteId: 'second' } }];
expect(validateDocumentStructure({ notes: {
first: { type: 'footnote' as const, noteId: 'first', body },
second: { type: 'footnote' as const, noteId: 'second', body: noteBody('Second') },
} }).map((issue) => issue.code)).toEqual(['nested-note']);
});
it.each(['headers', 'footers'] as const)('rejects references in %s and ambiguous note segment identities', (kind) => {
const body = noteBody('\uFFFC');
body.customRanges = [{ rangeType: CustomRangeType.FOOTNOTE, rangeId: 'reference', startIndex: 0, endIndex: 0, wholeEntity: true, properties: { noteId: 'note' } }];
const notes = { note: { type: 'footnote' as const, noteId: 'note', body: noteBody('Explanation') } };
const running = { headerId: 'running', footerId: 'running', body };
expect(validateDocumentStructure({ [kind]: { running }, notes })).toEqual([
expect.objectContaining({ code: 'invalid-note-reference', segmentId: 'running', index: 0 }),
]);
const colliding = { headerId: 'note', footerId: 'note', body: noteBody('Running text') };
expect(validateDocumentStructure({ [kind]: { note: colliding }, notes })).toEqual([
expect.objectContaining({ code: 'invalid-note-id', segmentType: 'note', segmentId: 'note' }),
]);
});
});
@@ -254,6 +254,68 @@ describe('drawing build utils', () => {
expect(doc.getBody()?.dataStream).toBe('A\bB\r\n');
});
it('keeps inserted and replaced footnote images scoped through undo and redo', () => {
const doc = new DocumentDataModel({
id: 'doc-note-images',
body: { dataStream: 'Main\r\n' },
notes: { note: { type: 'footnote' as const, noteId: 'note', body: { dataStream: 'Note\r\n' } } },
});
const before = doc.getSnapshot();
const drawings = ['image-1', 'image-2'].map((drawingId) => ({
drawingId,
unitId: 'doc-note-images',
subUnitId: 'doc-note-images',
drawingType: DrawingTypeEnum.DRAWING_IMAGE,
layoutType: PositionedObjectLayoutType.INLINE,
docTransform: {
size: { width: 20, height: 20 },
positionH: { relativeFrom: 0, posOffset: 0 },
positionV: { relativeFrom: 0, posOffset: 0 },
},
}));
try {
const insert = addDrawing({
selection: { startOffset: 4, endOffset: 4, collapsed: true, segmentId: 'note' },
documentDataModel: doc,
drawings,
});
if (!insert) {
throw new Error('Expected footnote drawing insertion');
}
const undoInsert = JSONX.invertWithDoc(insert, before);
doc.apply(insert);
const inserted = doc.getSnapshot();
expect(inserted.notes?.note.body.dataStream).toBe('Note\b\b\r\n');
expect(inserted.notes?.note.drawingsOrder).toEqual(['image-1', 'image-2']);
expect(inserted.notes?.note.drawings).toMatchObject({ 'image-1': drawings[0], 'image-2': drawings[1] });
expect(inserted.body).toEqual(before.body);
expect(inserted.drawings).toEqual(before.drawings);
expect(inserted.drawingsOrder).toEqual(before.drawingsOrder);
const replace = addDrawing({
selection: { startOffset: 4, endOffset: 6, collapsed: false, segmentId: 'note' },
documentDataModel: doc,
drawings: [{ ...drawings[0], drawingId: 'replacement' }],
});
if (!replace) {
throw new Error('Expected footnote drawing replacement');
}
const undoReplace = JSONX.invertWithDoc(replace, inserted);
doc.apply(replace);
expect(doc.getSnapshot().notes?.note.drawingsOrder).toEqual(['replacement']);
expect(Object.keys(doc.getSnapshot().notes?.note.drawings ?? {})).toEqual(['replacement']);
expect(doc.getSnapshot().notes?.note.body.dataStream).toBe('Note\b\r\n');
doc.apply(undoReplace);
expect(doc.getSnapshot()).toEqual(inserted);
doc.apply(undoInsert);
expect(doc.getSnapshot()).toEqual(before);
doc.apply(insert);
expect(doc.getSnapshot()).toEqual(inserted);
} finally {
doc.dispose();
}
});
it('should return false when drawing insertion targets a missing body', () => {
const documentDataModel = new DocumentDataModel({ id: 'doc-without-body' });
delete documentDataModel.getSnapshot().body;
@@ -56,16 +56,20 @@ export function getCustomBlockIdsInSelections(body: IDocumentBody, selections: I
}
export function removeDrawingReferences(
documentData: Pick<IDocumentData, 'body' | 'drawings' | 'drawingsOrder'>,
documentData: Pick<IDocumentData, 'body' | 'drawings' | 'drawingsOrder' | 'notes'>,
selections: ITextRange[],
body: IDocumentBody | undefined = documentData.body
body: IDocumentBody | undefined = documentData.body,
segmentId = ''
): JSONXActions[] {
if (!body) {
return [];
}
const drawings = documentData.drawings ?? {};
const drawingOrder = documentData.drawingsOrder ?? [];
const footnote = documentData.notes?.[segmentId];
const source = footnote ?? documentData;
const prefix = footnote ? ['notes', segmentId] : [];
const drawings = source.drawings ?? {};
const drawingOrder = source.drawingsOrder ?? [];
const blockIds = [...new Set(getCustomBlockIdsInSelections(body, selections))]
.sort((left, right) => drawingOrder.indexOf(right) - drawingOrder.indexOf(left));
const jsonX = JSONX.getInstance();
@@ -74,7 +78,7 @@ export function removeDrawingReferences(
for (const blockId of blockIds) {
const drawing = drawings[blockId];
if (drawing != null) {
const removeDrawingAction = jsonX.removeOp(['drawings', blockId], drawing);
const removeDrawingAction = jsonX.removeOp([...prefix, 'drawings', blockId], drawing);
if (removeDrawingAction) {
actions.push(removeDrawingAction);
}
@@ -82,7 +86,7 @@ export function removeDrawingReferences(
const drawingIndex = drawingOrder.indexOf(blockId);
if (drawingIndex >= 0) {
const removeDrawingOrderAction = jsonX.removeOp(['drawingsOrder', drawingIndex], blockId);
const removeDrawingOrderAction = jsonX.removeOp([...prefix, 'drawingsOrder', drawingIndex], blockId);
if (removeDrawingOrderAction) {
actions.push(removeDrawingOrderAction);
}
@@ -92,7 +96,6 @@ export function removeDrawingReferences(
return actions;
}
// eslint-disable-next-line max-lines-per-function
export const addDrawing = (param: IAddDrawingParam) => {
const { selection, documentDataModel, drawings } = param;
const { collapsed, startOffset, segmentId } = selection;
@@ -105,11 +108,21 @@ export const addDrawing = (param: IAddDrawingParam) => {
return false;
}
const drawingOrderLength = documentDataModel.getSnapshot().drawingsOrder?.length ?? 0;
let removeDrawingLen = 0;
const snapshot = documentDataModel.getSnapshot();
const footnote = snapshot.notes?.[segmentId ?? ''];
const source = footnote ?? snapshot;
const prefix = footnote ? ['notes', segmentId!] : [];
const drawingOrder = source.drawingsOrder ?? [];
let insertDrawingIndex = drawingOrder.length;
if (source.drawings == null) {
rawActions.push(jsonX.insertOp([...prefix, 'drawings'], {})!);
}
if (source.drawingsOrder == null) {
rawActions.push(jsonX.insertOp([...prefix, 'drawingsOrder'], [])!);
}
const insertOffset = collapsed ? normalizeDrawingInsertOffset(body, startOffset ?? 0) : (startOffset ?? 0);
// Step 1: Insert placeholder `\b` in dataStream and add drawing to customBlocks.
// Step 1: Insert placeholder `\b` in dataStream and add drawing to customBlocks.
if (collapsed) {
if (insertOffset > 0) {
textX.push({
@@ -121,34 +134,11 @@ export const addDrawing = (param: IAddDrawingParam) => {
const dos = deleteSelectionTextX([selection], body, 0, null, false);
textX.push(...dos);
const removedCustomBlockIds = getCustomBlockIdsInSelections(body, [selection]);
const drawings = documentDataModel.getDrawings() ?? {};
const drawingOrder = documentDataModel.getDrawingsOrder() ?? [];
const sortedRemovedCustomBlockIds = removedCustomBlockIds.sort((a, b) => {
if (drawingOrder.indexOf(a) > drawingOrder.indexOf(b)) {
return -1;
} else if (drawingOrder.indexOf(a) < drawingOrder.indexOf(b)) {
return 1;
}
return 0;
});
if (sortedRemovedCustomBlockIds.length > 0) {
for (const blockId of sortedRemovedCustomBlockIds) {
const drawing = drawings[blockId];
const drawingIndex = drawingOrder.indexOf(blockId);
if (drawing == null || drawingIndex < 0) {
continue;
}
const removeDrawingAction = jsonX.removeOp(['drawings', blockId], drawing);
const removeDrawingOrderAction = jsonX.removeOp(['drawingsOrder', drawingIndex], blockId);
rawActions.push(removeDrawingAction!);
rawActions.push(removeDrawingOrderAction!);
removeDrawingLen++;
const removedIds = new Set(getCustomBlockIdsInSelections(body, [selection]));
insertDrawingIndex -= drawingOrder.filter((id) => removedIds.has(id)).length;
for (const action of removeDrawingReferences(snapshot, [selection], body, segmentId)) {
if (action) {
rawActions.push(action);
}
}
}
@@ -165,11 +155,11 @@ export const addDrawing = (param: IAddDrawingParam) => {
rawActions.push(placeHolderAction!);
// Step 2: add drawing to drawings and drawingsOrder fields.
// Step 2: add drawing to drawings and drawingsOrder fields.
for (const drawing of drawings) {
const { drawingId } = drawing;
const addDrawingAction = jsonX.insertOp(['drawings', drawingId], drawing);
const addDrawingOrderAction = jsonX.insertOp(['drawingsOrder', drawingOrderLength - removeDrawingLen], drawingId);
const addDrawingAction = jsonX.insertOp([...prefix, 'drawings', drawingId], drawing);
const addDrawingOrderAction = jsonX.insertOp([...prefix, 'drawingsOrder', insertDrawingIndex++], drawingId);
rawActions.push(addDrawingAction!);
rawActions.push(addDrawingOrderAction!);
@@ -14,7 +14,8 @@
* limitations under the License.
*/
import type { ICustomTable, IDocumentBody, IDocumentData } from '../../../types/interfaces/i-document-data';
import type { ICustomTable, IDocumentBody, IDocumentData, IDocumentNote } from '../../../types/interfaces/i-document-data';
import { CustomRangeType, PositionedObjectLayoutType, TableTextWrapType } from '../../../types/interfaces/i-document-data';
import { DataStreamTreeTokenType } from '../types';
import {
getBlockRangeInterval,
@@ -24,6 +25,12 @@ import {
} from './build-utils/range-interval';
export type DocStructureIssueCode =
| 'missing-note'
| 'invalid-note-reference'
| 'nested-note'
| 'invalid-note-body'
| 'invalid-note-id'
| 'duplicate-note-reference'
| 'missing-root-paragraph'
| 'missing-root-section-break'
| 'paragraph-token-mismatch'
@@ -54,7 +61,7 @@ export type DocStructureIssueCode =
export interface IDocStructureIssue {
code: DocStructureIssueCode;
segmentType: 'body' | 'header' | 'footer';
segmentType: 'body' | 'header' | 'footer' | 'note';
segmentId?: string;
index?: number;
message: string;
@@ -546,14 +553,61 @@ export function validateDocBodyStructure(
validateCustomBlockMetadata(body, scan, issues, context);
validateStructuralContainers(body, issues, context);
if (context.segmentType === 'header' || context.segmentType === 'footer') {
for (const range of body.customRanges ?? []) {
if ((range.rangeType === CustomRangeType.FOOTNOTE || range.rangeType === CustomRangeType.ENDNOTE)) {
issues.push(createIssue(context, 'invalid-note-reference', 'Note references are only supported in the document body.', range.startIndex));
}
}
}
return issues;
}
export function validateDocumentStructure(snapshot: Pick<IDocumentData, 'body' | 'headers' | 'footers'>): IDocStructureIssue[] {
function validateNoteBody(noteId: string, note: IDocumentNote): IDocStructureIssue[] {
const context: IValidationContext = { segmentType: 'note', segmentId: noteId };
const issues = validateDocBodyStructure(note.body, context);
if (note.noteId !== noteId || noteId.length === 0 || !['footnote', 'endnote'].includes(note.type)) {
issues.push({ ...context, code: 'invalid-note-id', message: 'Note identity must match its segment key.' });
}
if (note.body.customRanges?.some((range) => (range.rangeType === CustomRangeType.FOOTNOTE || range.rangeType === CustomRangeType.ENDNOTE))) {
issues.push({ ...context, code: 'nested-note', message: 'A note cannot contain another note reference.' });
}
const rootSections = note.body.sectionBreaks?.filter((section) =>
!note.body.tables?.some((table) => section.startIndex >= table.startIndex && section.startIndex < table.endIndex)) ?? [];
const hasPageSetup = rootSections.some((section) => Object.keys(section).some((key) => key !== 'sectionId' && key !== 'startIndex'));
const hasFloatingDrawings = Object.values(note.drawings ?? {}).some((drawing) => drawing.layoutType !== PositionedObjectLayoutType.INLINE);
const hasFloatingTables = Object.values(note.tableSource ?? {}).some((table) => table.textWrap === TableTextWrapType.WRAP);
if (rootSections.length > 1 || hasPageSetup || note.body.columnGroups?.length || hasFloatingDrawings || hasFloatingTables) {
issues.push({ ...context, code: 'invalid-note-body', message: 'Notes support paragraphs, inline drawings and flow tables without independent sections or columns.' });
}
return issues;
}
export function validateDocumentStructure(snapshot: Pick<IDocumentData, 'body' | 'headers' | 'footers' | 'notes'>): IDocStructureIssue[] {
const issues: IDocStructureIssue[] = [];
if (snapshot.body) {
issues.push(...validateDocBodyStructure(snapshot.body, { segmentType: 'body' }));
const referencedNotes = new Set<string>();
for (const range of snapshot.body.customRanges ?? []) {
if ((range.rangeType !== CustomRangeType.FOOTNOTE && range.rangeType !== CustomRangeType.ENDNOTE)) {
continue;
}
const noteId = range.properties?.noteId;
if (typeof noteId !== 'string' || snapshot.notes?.[noteId] == null) {
issues.push({ code: 'missing-note', segmentType: 'body', index: range.startIndex, message: 'Note reference must resolve to a note segment.' });
} else if (snapshot.notes[noteId].type !== (range.rangeType === CustomRangeType.ENDNOTE ? 'endnote' : 'footnote')) {
issues.push({ code: 'invalid-note-reference', segmentType: 'body', index: range.startIndex, message: 'Note reference kind must match its target.' });
} else if (referencedNotes.has(noteId)) {
issues.push({ code: 'duplicate-note-reference', segmentType: 'body', index: range.startIndex, message: 'Each note must have its own reference identity.' });
} else {
referencedNotes.add(noteId);
}
if (range.startIndex !== range.endIndex || snapshot.body.dataStream[range.startIndex] !== '\uFFFC' || !range.wholeEntity) {
issues.push({ code: 'invalid-note-reference', segmentType: 'body', index: range.startIndex, message: 'Note reference must cover one whole-entity reference character.' });
}
}
}
for (const [headerId, header] of Object.entries(snapshot.headers ?? {})) {
@@ -564,5 +618,12 @@ export function validateDocumentStructure(snapshot: Pick<IDocumentData, 'body' |
issues.push(...validateDocBodyStructure(footer.body, { segmentType: 'footer', segmentId: footerId }));
}
for (const [noteId, note] of Object.entries(snapshot.notes ?? {})) {
issues.push(...validateNoteBody(noteId, note));
if (snapshot.headers?.[noteId] || snapshot.footers?.[noteId]) {
issues.push({ code: 'invalid-note-id', segmentType: 'note', segmentId: noteId, message: 'Notes must not share a segment identity with a header or footer.' });
}
}
return issues;
}
@@ -770,6 +770,9 @@ export function getRichTextEditPath(docDataModel: DocumentDataModel, segmentId =
}
const { headers, footers } = docDataModel.getSnapshot();
if (docDataModel.getSnapshot().notes?.[segmentId] != null) {
return ['notes', segmentId, 'body'];
}
if (headers == null && footers == null) {
throw new Error('Document data model must have headers or footers when update by segment id');
@@ -34,6 +34,9 @@ export interface IDocumentData extends IReferenceSource {
locale?: LocaleType;
title?: string;
body?: IDocumentBody; // Rich text.
/** Independent rich-text segments addressed by footnote or endnote references in the main body. */
notes?: Record<string, IDocumentNote>;
noteSettings?: INoteSettings;
documentStyle: IDocumentStyle;
/** OOXML-compatible named document styles keyed by stable style id. */
styles?: IDocStyles;
@@ -57,6 +60,63 @@ export interface IDocumentSettings {
zoomRatio?: number;
}
/** Settings inherit from the document into each section; absent values do not reset inheritance. */
export interface IFootnoteProperties {
position?: 'pageBottom' | 'beneathText';
/** OOXML ST_NumberFormat name, shared by the import/export and numbering engines. */
numberFormat?: string;
startNumber?: number;
restart?: 'continuous' | 'eachSect' | 'eachPage';
/** Zero matches the body section's column count. */
columnCount?: number;
}
export interface IFootnoteSettings extends IFootnoteProperties {
separator?: IDocumentBody;
continuationSeparator?: IDocumentBody;
continuationNotice?: IDocumentBody;
}
export type DocumentNoteType = 'footnote' | 'endnote';
export interface IEndnoteProperties extends Omit<IFootnoteProperties, 'position' | 'restart' | 'columnCount'> {
position?: 'docEnd' | 'sectEnd';
restart?: 'continuous' | 'eachSect';
}
export interface IEndnoteSettings extends IEndnoteProperties {
separator?: IDocumentBody;
continuationSeparator?: IDocumentBody;
continuationNotice?: IDocumentBody;
}
export interface INoteSettings {
footnote?: IFootnoteSettings;
endnote?: IEndnoteSettings;
}
export interface INoteProperties {
footnote?: IFootnoteProperties;
endnote?: IEndnoteProperties;
}
/** Notes have editable content, but no independent page setup or nested notes. */
export interface IDocumentNote {
noteId: string;
type: DocumentNoteType;
body: IDocumentBody;
tableSource?: ITables;
drawings?: IDrawings;
drawingsOrder?: string[];
lists?: ILists;
/** Custom marks do not consume a number in the automatic sequence. */
customMark?: string;
/** Style of the generated note marker; it does not affect text inserted at body offset zero. */
referenceTextStyle?: ITextStyle;
}
export type IFootnoteCustomRange = ICustomRange<{ noteId: string }>;
/**
* Set of headers
*/
@@ -437,6 +497,8 @@ export enum CustomRangeType {
CUSTOM,
MENTION,
UNI_FORMULA,
FOOTNOTE,
ENDNOTE,
DELTED = 9999,
}
@@ -538,6 +600,8 @@ export interface IDocumentLayout {
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;
/** OOXML `splitPgBreakAndParaMark`: move a trailing paragraph mark after a manual page break. */
splitPageBreakAndParagraphMark?: BooleanNumber;
/** Align automatic line height inside tables to the active document line grid. */
adjustLineHeightInTable?: BooleanNumber;
paragraphLineGapDefault?: number; // paragraphLineGapDefault default line spacing
@@ -597,6 +661,9 @@ export interface IDocumentRenderConfig {
}
export interface ISectionBreakBase {
noteProperties?: INoteProperties;
/** Defer section-end notes to the next section that does not suppress them. */
suppressEndnotes?: boolean;
// docGrid (Document Grid), open xml $17.6.5
charSpace?: number; // charSpace
linePitch?: number; // linePitch
@@ -19,6 +19,7 @@ import type { IDocDrawing } from '@univerjs/docs-drawing';
import type { RenderUnit } from '@univerjs/engine-render';
import {
BooleanNumber,
CustomRangeType,
DocumentFlavor,
DrawingTypeEnum,
ICommandService,
@@ -242,6 +243,47 @@ describe('DocDrawingAddRemoveController with real commands and services', () =>
removeSubscription.unsubscribe();
});
it('registers scoped note images and removes them when their note is deleted', () => {
const body = model.getBody()!;
const referencedBody = {
...body,
dataStream: `\uFFFC${body.dataStream}`,
paragraphs: body.paragraphs?.map((paragraph) => ({ ...paragraph, startIndex: paragraph.startIndex + 1 })),
sectionBreaks: body.sectionBreaks?.map((section) => ({ ...section, startIndex: section.startIndex + 1 })),
customBlocks: body.customBlocks?.map((block) => ({ ...block, startIndex: block.startIndex + 1 })),
customRanges: [{
rangeId: 'ref',
rangeType: CustomRangeType.FOOTNOTE,
startIndex: 0,
endIndex: 0,
wholeEntity: true,
properties: { noteId: 'note' },
}],
};
const note = { type: 'footnote' as const, noteId: 'note', body: {
dataStream: '\b\r\n',
paragraphs: [{ startIndex: 1, paragraphId: 'np' }],
customBlocks: [{ startIndex: 0, blockId: 'note-image' }],
}, drawings: { 'note-image': { ...drawing('note-image'), layoutType: PositionedObjectLayoutType.INLINE } }, drawingsOrder: ['note-image'] };
const jsonX = JSONX.getInstance();
mutate(JSONX.compose(jsonX.replaceOp(['body'], body, referencedBody), jsonX.insertOp(['notes', 'note'], note)));
const search = { unitId, subUnitId: unitId, drawingId: 'note-image' };
expect(manager.getDrawingByParam(search)?.drawingId).toBe('note-image');
expect(docDrawings.getDrawingOrder(unitId, unitId)).toEqual(['a', 'b', 'note-image']);
expect(commands.syncExecuteCommand(UpdateDrawingDocTransformCommand.id, {
unitId,
subUnitId: unitId,
drawings: [{ drawingId: 'note-image', key: 'size', value: { width: 70, height: 35 } }],
})).toBe(true);
expect(manager.getDrawingByParam(search)).toMatchObject({ docTransform: { size: { width: 70, height: 35 } } });
expect(model.getDrawings()).not.toHaveProperty('note-image');
mutate(jsonX.replaceOp(['body'], model.getBody(), body));
expect(model.getSnapshot().notes?.note).toBeUndefined();
expect(manager.getDrawingByParam(search)).toBeUndefined();
expect(docDrawings.getDrawingByParam(search)).toBeUndefined();
expect(manager.getDrawingByParam({ ...search, drawingId: 'a' })).toBeDefined();
});
it('updates persisted order and keeps behind-text drawings below foreground drawings', () => {
mutate(JSONX.getInstance().moveOp(['drawingsOrder', 0], ['drawingsOrder', 1]));
expect(docDrawings.getDrawingOrder(unitId, unitId)).toEqual(['b', 'a']);
@@ -29,6 +29,7 @@ import {
ICommandService,
Inject,
IUniverInstanceService,
JSON1,
JSONX,
PositionedObjectLayoutType,
RedoCommand,
@@ -37,6 +38,7 @@ import {
} from '@univerjs/core';
import { DocSkeletonManagerService, RichTextEditingMutation } from '@univerjs/docs';
import {
collectDocDrawings,
getDocDrawingRenderOrder,
IDocDrawingService,
TextWrappingStyle,
@@ -224,6 +226,16 @@ export class DocDrawingAddRemoveController extends Disposable {
const params = command.params as IRichTextEditingMutationParams;
const { unitId, actions } = params;
let changedNotes = false;
if (!JSONX.isNoop(actions)) {
const cursor = JSON1.type.readCursor(actions);
cursor.traverse(null, () => {
changedNotes ||= cursor.getPath()[0] === 'notes';
});
}
if (changedNotes) {
this._syncNoteDrawings(unitId);
}
const reOrderedDrawings = getReOrderedDrawings(actions);
if (reOrderedDrawings.length > 0) {
@@ -413,7 +425,7 @@ export class DocDrawingAddRemoveController extends Disposable {
return;
}
const { drawings, drawingsOrder } = documentDataModel.getSnapshot();
const { drawings, drawingsOrder } = collectDocDrawings(documentDataModel.getSnapshot());
if (drawingsOrder == null) {
return;
@@ -441,6 +453,27 @@ export class DocDrawingAddRemoveController extends Disposable {
});
}
private _syncNoteDrawings(unitId: string): void {
const model = this._univerInstanceService.getUnit<DocumentDataModel>(unitId, UniverInstanceType.UNIVER_DOC);
if (!model) {
return;
}
const { drawings } = collectDocDrawings(model.getSnapshot());
const previous = this._docDrawingService.getDrawingData(unitId, unitId) ?? {};
const added = Object.keys(drawings).filter((id) => !previous[id]);
const removed = Object.keys(previous).filter((id) => !drawings[id]);
const changed = Object.keys(drawings).filter((id) => drawings[id] !== previous[id]);
if (added.length > 0) {
this._addDrawings(unitId, added.map((id) => ({ ...drawings[id], unitId, subUnitId: unitId })));
}
if (removed.length > 0) {
this._removeDrawings(unitId, removed);
}
if (changed.length > 0) {
this._syncDrawingDataFromSnapshot(unitId, changed);
}
}
private _syncDrawingDataFromSnapshot(unitId: string, drawingIds: string[]) {
const documentDataModel = this._univerInstanceService.getUnit<DocumentDataModel>(unitId, UniverInstanceType.UNIVER_DOC);
@@ -448,7 +481,7 @@ export class DocDrawingAddRemoveController extends Disposable {
return;
}
const { drawings = {}, drawingsOrder = [] } = documentDataModel.getSnapshot();
const { drawings, drawingsOrder } = collectDocDrawings(documentDataModel.getSnapshot());
const drawingData = drawings as IDrawingMapItemData<IDocDrawing>;
const previousDrawings = this._docDrawingService.getDrawingData(unitId, unitId);
const orderChanged = drawingsOrder !== this._docDrawingService.getDrawingOrder(unitId, unitId) ||
@@ -33,7 +33,7 @@ import {
UniverInstanceType,
} from '@univerjs/core';
import { DocSkeletonManagerService } from '@univerjs/docs';
import { UpdateDrawingDocTransformCommand } from '@univerjs/docs-drawing';
import { findDocDrawing, UpdateDrawingDocTransformCommand } from '@univerjs/docs-drawing';
import { DocSelectionRenderService, getAnchorBounding, getOneTextSelectionRange, neoGetDocObject, NodePositionConvertToCursor, TEXT_RANGE_LAYER_INDEX } from '@univerjs/docs-ui';
import { IDrawingManagerService } from '@univerjs/drawing';
import { DocumentSkeletonPageType, getColor, IRenderManagerService, Liquid, PageLayoutType, Rect, Vector2 } from '@univerjs/engine-render';
@@ -162,7 +162,6 @@ export class DocDrawingTransformerController extends Disposable {
// Only handle one drawing transformer change.
// eslint-disable-next-line max-lines-per-function
private _listenTransformerChange(unitId: string): void {
const transformer = this._getSceneAndTransformerByDrawingSearch(unitId)?.transformer;
@@ -184,7 +183,7 @@ export class DocDrawingTransformerController extends Disposable {
}
const documentDataModel = this._univerInstanceService.getUnit<DocumentDataModel>(drawing.unitId, UniverInstanceType.UNIVER_DOC);
const drawingData = documentDataModel?.getSnapshot().drawings?.[drawing.drawingId];
const drawingData = documentDataModel ? findDocDrawing(documentDataModel.getSnapshot(), drawing.drawingId)?.drawing : undefined;
if (drawingData?.layoutType === PositionedObjectLayoutType.INLINE) {
try {
@@ -0,0 +1,65 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { IDocDrawing } from '@univerjs/docs-drawing';
import type { IImageData } from '@univerjs/drawing';
import { DrawingTypeEnum, ObjectRelativeFromH, ObjectRelativeFromV, PositionedObjectLayoutType } from '@univerjs/core';
import { describe, expect, it } from 'vitest';
import { getDocImageCropUpdates } from '../doc-drawing-crop';
function drawing(): IDocDrawing {
return {
unitId: 'doc',
subUnitId: 'doc',
drawingId: 'note-image',
drawingType: DrawingTypeEnum.DRAWING_IMAGE,
layoutType: PositionedObjectLayoutType.INLINE,
transform: { left: 40, top: 600, width: 100, height: 40 },
docTransform: {
angle: 0,
size: { width: 150, height: 60 },
positionH: { relativeFrom: ObjectRelativeFromH.PAGE, posOffset: 40 },
positionV: { relativeFrom: ObjectRelativeFromV.PAGE, posOffset: 600 },
},
};
}
describe('document image crop updates', () => {
it('does not turn an uncropped transformer notification into a second size mutation', () => {
const image = drawing();
const notification = { ...image, srcRect: undefined } as IImageData;
expect(getDocImageCropUpdates(notification, image, notification)).toEqual([]);
expect(image.docTransform.size).toEqual({ width: 150, height: 60 });
});
it('persists a real crop and permits an explicit reset without moving an inline anchor', () => {
const image = drawing();
const renderImage = image as IImageData;
const crop = { left: 10, right: 10, top: 4, bottom: 4 };
expect(getDocImageCropUpdates({
...renderImage,
srcRect: crop,
transform: { left: 50, top: 604, width: 80, height: 32 },
}, image, renderImage)).toEqual([
{ drawingId: 'note-image', key: 'srcRect', value: crop },
{ drawingId: 'note-image', key: 'size', value: { width: 80, height: 32 } },
]);
expect(getDocImageCropUpdates({ ...renderImage, srcRect: null }, image, renderImage)).toEqual([
{ drawingId: 'note-image', key: 'srcRect', value: null },
{ drawingId: 'note-image', key: 'size', value: { width: 100, height: 40 } },
]);
});
});
@@ -235,7 +235,7 @@ describe('DocDrawingTransformUpdateController', () => {
expect(tracker.shouldRefresh(skeleton, progress(2))).toBe(true);
});
it('refreshes when a published column group contains drawings', () => {
it.each(['column', 'footnote'])('refreshes when a published %s contains drawings', (scope) => {
const nestedPage = {
skeDrawings: new Map([['column-drawing', {}]]),
skeTables: new Map(),
@@ -247,9 +247,12 @@ describe('DocDrawingTransformUpdateController', () => {
pageWidth: 100,
skeDrawings: new Map(),
skeTables: new Map(),
skeColumnGroups: new Map([['column-group', {
columns: [{ page: nestedPage }],
}]]),
skeColumnGroups: new Map(scope === 'column'
? [['column-group', {
columns: [{ page: nestedPage }],
}]]
: []),
notes: scope === 'footnote' ? [{ page: nestedPage }] : [],
}];
const skeleton = {
getSkeletonData: () => ({
@@ -384,12 +384,13 @@ describe('DocDrawingUpdateRenderController', () => {
focus$.next([{ unitId: 'doc-1', subUnitId: 'doc-1', drawingId: 'body-drawing' }]);
expect(contextService.setContextValue).toHaveBeenCalledWith(FOCUSING_COMMON_DRAWINGS, true);
expect(docDrawingService.focusDrawing).toHaveBeenLastCalledWith([{ unitId: 'doc-1', subUnitId: 'doc-1', drawingId: 'body-drawing' }]);
expect(docSelectionManagerService.replaceDocRanges).toHaveBeenCalledWith([{ startOffset: 4, endOffset: 5 }]);
expect(docSelectionManagerService.replaceDocRanges).toHaveBeenCalledWith([{ startOffset: 4, endOffset: 5, segmentId: '' }]);
expect(docSelectionRenderService.setSegment).not.toHaveBeenCalled();
expect(transformer.resetProps).toHaveBeenLastCalledWith({ zeroTop: 18, zeroLeft: 12 });
focus$.next([{ unitId: 'doc-1', subUnitId: 'doc-1', drawingId: 'header-drawing' }]);
expect(docSelectionRenderService.setSegment).toHaveBeenCalledWith('header-1');
expect(docSelectionManagerService.replaceDocRanges).toHaveBeenLastCalledWith([{ startOffset: 2, endOffset: 3, segmentId: 'header-1' }]);
});
it('refreshes doc selection after transformer changes finish', async () => {
@@ -0,0 +1,71 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { IDocDrawing, IDrawingDocTransform } from '@univerjs/docs-drawing';
import type { IImageData } from '@univerjs/drawing';
import { BooleanNumber, DrawingTypeEnum, PositionedObjectLayoutType } from '@univerjs/core';
export function getDocImageCropUpdates(
param: IImageData,
docDrawing: IDocDrawing | undefined,
renderDrawing: IImageData | undefined | null
): IDrawingDocTransform[] {
const { drawingId, transform } = param;
// Transformer updates also carry an undefined srcRect for uncropped images.
// Their size is already persisted by the document transformer controller.
if (transform == null || param.srcRect === undefined) {
return [];
}
if (docDrawing?.drawingType !== DrawingTypeEnum.DRAWING_IMAGE || renderDrawing?.transform == null) {
return [];
}
const nextTransform = { ...renderDrawing.transform, ...transform };
const drawings: IDrawingDocTransform[] = [{ drawingId, key: 'srcRect', value: param.srcRect }];
if (nextTransform.width != null && nextTransform.height != null) {
drawings.push({
drawingId,
key: 'size',
value: { width: nextTransform.width, height: nextTransform.height },
});
}
if (docDrawing.layoutType === PositionedObjectLayoutType.INLINE || renderDrawing.isMultiTransform === BooleanNumber.TRUE) {
return drawings;
}
const horizontalDelta = (nextTransform.left ?? 0) - (renderDrawing.transform.left ?? 0);
const verticalDelta = (nextTransform.top ?? 0) - (renderDrawing.transform.top ?? 0);
const { positionH, positionV } = docDrawing.docTransform;
if (horizontalDelta !== 0 && positionH.posOffset != null) {
drawings.push({
drawingId,
key: 'positionH',
value: { relativeFrom: positionH.relativeFrom, posOffset: positionH.posOffset + horizontalDelta },
});
}
if (verticalDelta !== 0 && positionV.posOffset != null) {
drawings.push({
drawingId,
key: 'positionV',
value: { relativeFrom: positionV.relativeFrom, posOffset: positionV.posOffset + verticalDelta },
});
}
return drawings;
}
@@ -37,14 +37,15 @@ export function getDocMutationAffectedDrawingIds(actions: JSONXActions): DocMuta
sawComponent = true;
const path = cursor.getPath();
const root = String(path[0]);
if (root === 'drawings') {
const drawingId = path[1];
const drawingIndex = root === 'notes' ? 2 : 0;
if (path[drawingIndex] === 'drawings') {
const drawingId = path[drawingIndex + 1];
if (typeof drawingId === 'string' && drawingId.length > 0) {
drawingIds.add(drawingId);
} else {
needsFullRefresh = true;
}
} else if (root === 'drawingsOrder' || path.includes('customBlocks')) {
} else if (path[drawingIndex] === 'drawingsOrder' || (root === 'notes' && path.length <= 2) || path.includes('customBlocks')) {
needsFullRefresh = true;
}
});
@@ -161,6 +161,7 @@ export type DocumentDrawingPublicationProgress = Pick<
>;
interface IDocumentDrawingPublicationNestedPage {
notes?: Array<{ page: IDocumentDrawingPublicationNestedPage }>;
skeDrawings: ReadonlyMap<string, unknown>;
skeTables?: ReadonlyMap<string, {
rows: Array<{ cells: IDocumentDrawingPublicationNestedPage[] }>;
@@ -393,7 +394,7 @@ function hasHorizontalTableViewport(viewport: IDocsTableRenderViewport | null |
}
function hasSkeletonPageDrawings(page: IDocumentDrawingPublicationNestedPage): boolean {
if (page.skeDrawings.size > 0) {
if (page.skeDrawings.size > 0 || page.notes?.some((note) => hasSkeletonPageDrawings(note.page))) {
return true;
}
@@ -420,6 +421,9 @@ function hasSkeletonPageDrawings(page: IDocumentDrawingPublicationNestedPage): b
function countSkeletonPageDrawings(page: IDocumentDrawingPublicationNestedPage): number {
let count = page.skeDrawings.size;
page.notes?.forEach((note) => {
count += countSkeletonPageDrawings(note.page);
});
page.skeTables?.forEach((table) => {
table.rows.forEach((row) => {
row.cells.forEach((cell) => {
@@ -783,6 +787,20 @@ export class DocDrawingTransformUpdateController extends Disposable implements I
undefined,
selectable
);
for (const note of page.notes ?? []) {
this._collectSegmentDrawingPositions(
unitId,
note.page,
docsLeft,
docsTop,
updateDrawingMap,
note.top,
note.left,
undefined,
selectable,
{ top: note.top, left: note.left }
);
}
}
private _collectSegmentDrawingPositions(
@@ -794,7 +812,8 @@ export class DocDrawingTransformUpdateController extends Disposable implements I
marginTop: number,
marginLeft: number,
hostPage: IDocumentSkeletonPage | undefined,
selectable: boolean
selectable: boolean,
clipOffset?: { left: number; top: number }
): void {
this._calculateDrawingPosition(
unitId,
@@ -805,7 +824,8 @@ export class DocDrawingTransformUpdateController extends Disposable implements I
marginTop,
marginLeft,
hostPage,
selectable
selectable,
clipOffset
);
this._calculateTableCellDrawingPositions(
unitId,
@@ -48,7 +48,7 @@ import {
getDocumentEntityPermissionObjectId,
RichTextEditingMutation,
} from '@univerjs/docs';
import { IDocDrawingService, InsertDocDrawingCommand, SetDocDrawingArrangeCommand, UpdateDrawingDocTransformCommand } from '@univerjs/docs-drawing';
import { collectDocDrawings, findDocDrawing, IDocDrawingService, InsertDocDrawingCommand, SetDocDrawingArrangeCommand, UpdateDrawingDocTransformCommand } from '@univerjs/docs-drawing';
import { DocSelectionRenderService } from '@univerjs/docs-ui';
import {
DRAWING_IMAGE_ALLOW_IMAGE_LIST,
@@ -66,6 +66,7 @@ import { debounceTime } from 'rxjs';
import { GroupDocDrawingCommand } from '../../commands/commands/group-doc-drawing.command';
import { UngroupDocDrawingCommand } from '../../commands/commands/ungroup-doc-drawing.command';
import { DocRefreshDrawingsService } from '../../services/doc-refresh-drawings.service';
import { getDocImageCropUpdates } from './doc-drawing-crop';
import { getDocMutationAffectedDrawingIds } from './doc-drawing-mutation';
interface IImageInsertPosition {
@@ -345,58 +346,19 @@ export class DocDrawingUpdateRenderController extends Disposable implements IRen
}
private _getImageCropUpdates(param: IImageData): IDrawingDocTransform[] {
const { unitId, subUnitId, drawingId, transform } = param;
const { unitId, subUnitId, drawingId } = param;
if ([unitId, subUnitId].some((id) => id !== this._context.unitId)) {
return [];
}
if (transform == null || !Object.prototype.hasOwnProperty.call(param, 'srcRect')) {
return [];
}
const docDrawing = this._getDocDrawing(drawingId);
const renderDrawing = this._drawingManagerService.getDrawingByParam({ unitId, subUnitId, drawingId });
if (docDrawing?.drawingType !== DrawingTypeEnum.DRAWING_IMAGE || renderDrawing?.transform == null) {
return [];
}
const nextTransform = { ...renderDrawing.transform, ...transform };
const drawings: IDrawingDocTransform[] = [{ drawingId, key: 'srcRect', value: param.srcRect }];
if (nextTransform.width != null && nextTransform.height != null) {
drawings.push({
drawingId,
key: 'size',
value: { width: nextTransform.width, height: nextTransform.height },
});
}
if (docDrawing.layoutType === PositionedObjectLayoutType.INLINE || renderDrawing.isMultiTransform === BooleanNumber.TRUE) {
return drawings;
}
const horizontalDelta = (nextTransform.left ?? 0) - (renderDrawing.transform.left ?? 0);
const verticalDelta = (nextTransform.top ?? 0) - (renderDrawing.transform.top ?? 0);
const { positionH, positionV } = docDrawing.docTransform;
if (horizontalDelta !== 0 && positionH.posOffset != null) {
drawings.push({
drawingId,
key: 'positionH',
value: { relativeFrom: positionH.relativeFrom, posOffset: positionH.posOffset + horizontalDelta },
});
}
if (verticalDelta !== 0 && positionV.posOffset != null) {
drawings.push({
drawingId,
key: 'positionV',
value: { relativeFrom: positionV.relativeFrom, posOffset: positionV.posOffset + verticalDelta },
});
}
return drawings;
return getDocImageCropUpdates(
param,
this._getDocDrawing(drawingId),
this._drawingManagerService.getDrawingByParam({ unitId, subUnitId, drawingId }) as IImageData | undefined
);
}
private _getDocDrawing(drawingId: string): IDocDrawing | undefined {
return this._context.unit.getDrawings()?.[drawingId] as IDocDrawing | undefined;
return findDocDrawing(this._context.unit.getSnapshot(), drawingId)?.drawing as IDocDrawing | undefined;
}
private _groupDrawingListener() {
@@ -491,7 +453,7 @@ export class DocDrawingUpdateRenderController extends Disposable implements IRen
}
const snapshot = docDataModel.getSnapshot();
const { drawings = {} } = snapshot;
const { drawings } = collectDocDrawings(snapshot);
const isEditBody = viewModel.getEditArea() === DocumentEditArea.BODY;
const isDocInteractionFocusing = this._docSelectionRenderService.isFocusing
|| this._drawingManagerService.getFocusDrawings().some((drawing) => drawing.unitId === unitId);
@@ -719,15 +681,12 @@ export class DocDrawingUpdateRenderController extends Disposable implements IRen
private _setDrawingSelections(params: IDrawingParam[]) {
const { unit } = this._context;
const customBlocks = unit.getSnapshot().body?.customBlocks ?? [];
const ranges = params.map((item) => {
const id = item.drawingId;
const block = customBlocks.find((b) => b.blockId === id);
if (block) {
return block.startIndex;
}
return null;
}).filter((e) => e !== null).map((offset) => ({ startOffset: offset, endOffset: offset + 1 }));
const ranges = params.flatMap((item) => {
const segmentId = getDocumentDrawingSegmentId(unit, item.drawingId);
const body = unit.getSelfOrHeaderFooterModel(segmentId)?.getBody();
const block = body?.customBlocks?.find((block) => block.blockId === item.drawingId);
return block ? [{ startOffset: block.startIndex, endOffset: block.startIndex + 1, segmentId }] : [];
});
this._docSelectionManagerService.replaceDocRanges(ranges);
}
}
@@ -323,6 +323,9 @@ export class DocDrawingPopupMenuController extends RxDisposable {
object,
{
componentKey: COMPONENT_IMAGE_POPUP_MENU,
// Object anchors follow transform updates. Keeping this toolbar
// open must not suspend publication after a resize or move.
requiresStableLayout: false,
direction: isImage || isChart ? 'top-center' : 'horizontal',
offset: isImage || isChart ? [0, 8] : [2, 0],
extraProps: {
@@ -21,8 +21,10 @@ import {
BooleanNumber,
BuildTextUtils,
CommandType,
DrawingTypeEnum,
ICommandService,
IUniverInstanceService,
PositionedObjectLayoutType,
UniverInstanceType,
} from '@univerjs/core';
import {
@@ -74,14 +76,21 @@ export const InsertDocDrawingCommand: ICommand = {
}
const snapshot = documentDataModel.getSnapshot();
const isFootnote = !!snapshot.notes?.[segmentId];
if (isFootnote && drawings.some((drawing) => drawing.drawingType !== DrawingTypeEnum.DRAWING_IMAGE)) {
return false;
}
const isHeaderFooter = !!snapshot.headers?.[segmentId] || !!snapshot.footers?.[segmentId];
const targetDrawings = isHeaderFooter
let targetDrawings = isHeaderFooter
? drawings.map((drawing) => ({
...drawing,
isMultiTransform: BooleanNumber.TRUE,
transforms: drawing.transforms ?? (drawing.transform ? [drawing.transform] : null),
}))
: drawings;
if (isFootnote) {
targetDrawings = drawings.map((drawing) => ({ ...drawing, layoutType: PositionedObjectLayoutType.INLINE }));
}
const actions = BuildTextUtils.drawing.add({
selection: resolvedTextRange,
documentDataModel,
@@ -96,6 +105,7 @@ export const InsertDocDrawingCommand: ICommand = {
id: RichTextEditingMutation.id,
params: {
unitId,
segmentId,
actions: [],
textRanges: [],
},
@@ -32,6 +32,7 @@ import {
} from '@univerjs/core';
import { DocHistoryAction, DocSelectionManagerService, getContentInsertRange, normalizeTextRange, RichTextEditingMutation } from '@univerjs/docs';
import { IDocDrawingAdapterService } from '../../services/doc-drawing-adapter.service';
import { findDocDrawing } from '../../services/doc-drawing-source';
export interface IRemoveDocDrawingCommandParam {
unitId: string;
@@ -49,7 +50,6 @@ export interface IRemoveDocDrawingCommandParams {
export const RemoveDocDrawingCommand: ICommand = {
id: 'doc.command.remove-doc-image',
type: CommandType.COMMAND,
// eslint-disable-next-line max-lines-per-function
handler: (accessor: IAccessor, params?: IRemoveDocDrawingCommandParams) => {
if (!params) {
return false;
@@ -70,7 +70,15 @@ export const RemoveDocDrawingCommand: ICommand = {
const activeTextRange = docSelectionManagerService.getActiveTextRange();
const explicitTextRange = !textRange ? null : normalizeTextRange(textRange);
const contentInsertRange = explicitTextRange ?? getContentInsertRange(accessor, unitId);
const segmentId = contentInsertRange?.segmentId ?? activeTextRange?.segmentId ?? '';
const snapshot = documentDataModel.getSnapshot();
const noteSegments = removeDrawings.map(({ drawingId }) => findDocDrawing(snapshot, drawingId)?.segmentId ?? '');
if (noteSegments.some((id) => id !== noteSegments[0])) {
return false;
}
const segmentId = noteSegments[0] || (contentInsertRange?.segmentId ?? activeTextRange?.segmentId ?? '');
const footnote = snapshot.notes?.[segmentId];
const source = footnote ?? snapshot;
const prefix = footnote ? ['notes', segmentId] : [];
const textX = new TextX();
const jsonX = JSONX.getInstance();
@@ -84,7 +92,7 @@ export const RemoveDocDrawingCommand: ICommand = {
return false;
}
const drawings = documentDataModel.getDrawings() ?? {};
const drawings = source.drawings ?? {};
const removeDrawingParamById = new Map(removeDrawings.map((drawing) => [drawing.drawingId, drawing]));
const removeDrawingSnapshots = removeCustomBlocks
.map((block) => drawings[block!.blockId] as IDocDrawing | undefined)
@@ -140,7 +148,7 @@ export const RemoveDocDrawingCommand: ICommand = {
const historyActions = getHistoryActions(removeDrawings);
const doMutation: IMutationInfo<IRichTextEditingMutationParams> = {
id: RichTextEditingMutation.id,
params: { unitId, actions: [], textRanges, historyActions },
params: { unitId, segmentId, actions: [], textRanges, historyActions },
};
const rawActions: JSONXActions = [];
@@ -155,11 +163,19 @@ export const RemoveDocDrawingCommand: ICommand = {
rawActions.push(jsonX.editOp(textX.serialize(), getRichTextEditPath(documentDataModel, segmentId))!);
for (const block of removeCustomBlocks) {
const drawingOrder = source.drawingsOrder ?? [];
const resourceBlocks = [...removeCustomBlocks].sort((left, right) =>
drawingOrder.indexOf(right!.blockId) - drawingOrder.indexOf(left!.blockId)
);
for (const block of resourceBlocks) {
const { blockId } = block!;
const drawingIndex = documentDataModel.getDrawingsOrder()!.indexOf(blockId);
rawActions.push(jsonX.removeOp(['drawings', blockId], drawings[blockId])!);
rawActions.push(jsonX.removeOp(['drawingsOrder', drawingIndex], blockId)!);
const drawingIndex = drawingOrder.indexOf(blockId);
if (drawings[blockId]) {
rawActions.push(jsonX.removeOp([...prefix, 'drawings', blockId], drawings[blockId])!);
}
if (drawingIndex >= 0) {
rawActions.push(jsonX.removeOp([...prefix, 'drawingsOrder', drawingIndex], blockId)!);
}
}
doMutation.params.actions = rawActions.reduce((acc, cur) => JSONX.compose(acc, cur as JSONXActions), null as JSONXActions);
@@ -28,6 +28,8 @@ import {
} from '@univerjs/core';
import { DocHistoryAction, RichTextEditingMutation } from '@univerjs/docs';
import { findDocDrawing } from '../../services/doc-drawing-source';
export interface IDrawingDocTransform {
drawingId: string;
key: 'size' | 'angle' | 'positionH' | 'positionV' | 'flipX' | 'flipY' | 'srcRect';
@@ -57,9 +59,9 @@ export const UpdateDrawingDocTransformCommand: ICommand = {
return false;
}
const oldDrawings = documentDataModel.getSnapshot().drawings ?? {};
const snapshot = documentDataModel.getSnapshot();
const historyAction = drawings.length > 0 && drawings.every(({ drawingId }) =>
oldDrawings[drawingId]?.drawingType === DrawingTypeEnum.DRAWING_IMAGE
findDocDrawing(snapshot, drawingId)?.drawing.drawingType === DrawingTypeEnum.DRAWING_IMAGE
)
? DocHistoryAction.UpdateImage
: undefined;
@@ -67,14 +69,18 @@ export const UpdateDrawingDocTransformCommand: ICommand = {
const actions: JSONXActions = [];
for (const { drawingId, key, value } of drawings) {
const oldDrawing = oldDrawings[drawingId];
const source = findDocDrawing(snapshot, drawingId);
if (!source) {
return false;
}
const oldDrawing = source.drawing;
const oldValue = key === 'srcRect'
? (oldDrawing as IDocImage | undefined)?.srcRect
: oldDrawing?.docTransform?.[key];
if (!Tools.diffValue(oldValue, value)) {
const path = key === 'srcRect'
? ['drawings', drawingId, key]
: ['drawings', drawingId, 'docTransform', key];
? [...source.path, key]
: [...source.path, 'docTransform', key];
// Optional transform fields such as flips do not exist in older documents.
actions.push(oldValue === undefined
? jsonX.insertOp(path, value)!
@@ -36,6 +36,8 @@ import { DocHistoryAction, RichTextEditingMutation } from '@univerjs/docs';
* `INLINE`, `WRAP_SQUARE`, and `WRAP_TOP_AND_BOTTOM` participate in text layout and can cause text to reflow.
* `BEHIND_TEXT` and `IN_FRONT_OF_TEXT` are overlay styles and do not cause text to reflow.
*/
import { findDocDrawing } from '../../services/doc-drawing-source';
export enum TextWrappingStyle {
/**
* Places the drawing in the text flow like a character.
@@ -106,9 +108,12 @@ export const UpdateDocDrawingWrappingStyleCommand: ICommand = {
return false;
}
const oldDrawings = documentDataModel.getDrawings() ?? {};
const snapshot = documentDataModel.getSnapshot();
if (wrappingStyle !== TextWrappingStyle.INLINE && drawings.some(({ drawingId }) => findDocDrawing(snapshot, drawingId)?.segmentId)) {
return false;
}
const historyAction = drawings.length > 0 && drawings.every(({ drawingId }) =>
oldDrawings[drawingId]?.drawingType === DrawingTypeEnum.DRAWING_IMAGE
findDocDrawing(snapshot, drawingId)?.drawing.drawingType === DrawingTypeEnum.DRAWING_IMAGE
)
? DocHistoryAction.UpdateImage
: undefined;
@@ -116,20 +121,21 @@ export const UpdateDocDrawingWrappingStyleCommand: ICommand = {
const rawActions: JSONXActions = [];
for (const drawing of drawings) {
const oldDrawing = oldDrawings[drawing.drawingId] as IDocDrawing | undefined;
if (!oldDrawing) {
const source = findDocDrawing(snapshot, drawing.drawingId);
const oldDrawing = source?.drawing as IDocDrawing | undefined;
if (!oldDrawing || !source) {
continue;
}
const layoutType = WRAPPING_STYLE_TO_LAYOUT_TYPE[wrappingStyle];
if (oldDrawing.layoutType !== layoutType) {
rawActions.push(jsonX.replaceOp(['drawings', drawing.drawingId, 'layoutType'], oldDrawing.layoutType, layoutType)!);
rawActions.push(jsonX.replaceOp([...source.path, 'layoutType'], oldDrawing.layoutType, layoutType)!);
}
if (wrappingStyle === TextWrappingStyle.BEHIND_TEXT || wrappingStyle === TextWrappingStyle.IN_FRONT_OF_TEXT) {
const behindDoc = wrappingStyle === TextWrappingStyle.BEHIND_TEXT ? BooleanNumber.TRUE : BooleanNumber.FALSE;
if (oldDrawing.behindDoc !== behindDoc) {
rawActions.push(jsonX.replaceOp(['drawings', drawing.drawingId, 'behindDoc'], oldDrawing.behindDoc, behindDoc)!);
rawActions.push(jsonX.replaceOp([...source.path, 'behindDoc'], oldDrawing.behindDoc, behindDoc)!);
}
}
@@ -138,7 +144,7 @@ export const UpdateDocDrawingWrappingStyleCommand: ICommand = {
const value = drawing.docTransform?.[key];
const oldValue = oldDrawing.docTransform[key];
if (value && !Tools.diffValue(oldValue, value)) {
rawActions.push(jsonX.replaceOp(['drawings', drawing.drawingId, 'docTransform', key], oldValue, value)!);
rawActions.push(jsonX.replaceOp([...source.path, 'docTransform', key], oldValue, value)!);
}
}
}
@@ -14,182 +14,88 @@
* limitations under the License.
*/
import { BooleanNumber, PositionedObjectLayoutType, UniverInstanceType } from '@univerjs/core';
import { describe, expect, it, vi } from 'vitest';
import type { IDocDrawing } from '../../services/doc-drawing.service';
import { BooleanNumber, DrawingTypeEnum, IResourceManagerService, PositionedObjectLayoutType, UniverInstanceType } from '@univerjs/core';
import { IDrawingManagerService } from '@univerjs/drawing';
import { describe, expect, it } from 'vitest';
import { createFacadeTestBed } from '../../facade/__tests__/create-test-bed';
import { IDocDrawingService } from '../../services/doc-drawing.service';
import { DocDrawingController, DOCS_DRAWING_PLUGIN } from '../doc-drawing.controller';
function drawing(drawingId: string): IDocDrawing {
return {
drawingId,
unitId: 'test-doc',
subUnitId: 'stale-import-unit',
drawingType: DrawingTypeEnum.DRAWING_IMAGE,
layoutType: PositionedObjectLayoutType.WRAP_NONE,
docTransform: { size: { width: 320, height: 180 }, angle: 15, positionH: { relativeFrom: 0, posOffset: 12 }, positionV: { relativeFrom: 0, posOffset: 34 } },
};
}
describe('DocDrawingController', () => {
it('should serialize snapshot and load resources into drawing services', () => {
const registerDrawingData = vi.fn();
const registerDrawingDataForManager = vi.fn();
const removeDrawingDataForUnit = vi.fn();
const removeDrawingDataForUnitFromManager = vi.fn();
let snapshot: any = {
drawings: { d1: { id: 'd1', drawingType: 'image' } },
drawingsOrder: ['d1'],
};
const doc = {
getSnapshot: () => snapshot,
resetDrawing: (data: any, order: any) => {
snapshot = { ...snapshot, drawings: data, drawingsOrder: order };
},
getDrawings: () => snapshot.drawings,
getDrawingsOrder: () => snapshot.drawingsOrder,
};
const univerInstanceService = {
getUnit: vi.fn((_unitId: string, _type?: UniverInstanceType) => doc),
};
let capturedResource: any;
const resourceManagerService = {
registerPluginResource: vi.fn((resource: any) => {
capturedResource = resource;
return { dispose: vi.fn() };
}),
};
const controller = new DocDrawingController(
{ registerDrawingData, removeDrawingDataForUnit } as any,
{
registerDrawingData: registerDrawingDataForManager,
removeDrawingDataForUnit: removeDrawingDataForUnitFromManager,
} as any,
resourceManagerService as any,
univerInstanceService as any,
{
registerCommand: vi.fn(() => ({ dispose: vi.fn() })),
} as any
);
expect(resourceManagerService.registerPluginResource).toHaveBeenCalledTimes(1);
expect(capturedResource.pluginName).toBe(DOCS_DRAWING_PLUGIN);
expect(capturedResource.businesses).toEqual([UniverInstanceType.UNIVER_DOC]);
const json = capturedResource.toJson('doc-1');
const parsed = JSON.parse(json);
expect(parsed.data).toEqual(snapshot.drawings);
expect(parsed.order).toEqual(snapshot.drawingsOrder);
expect(capturedResource.parseJson('')).toEqual({ data: {}, order: [] });
expect(capturedResource.parseJson('{bad json')).toEqual({ data: {}, order: [] });
capturedResource.onLoad('doc-1', {
data: {
mask: {
id: 'mask',
layoutType: PositionedObjectLayoutType.WRAP_NONE,
behindDoc: BooleanNumber.FALSE,
},
photo: {
id: 'photo',
layoutType: PositionedObjectLayoutType.WRAP_NONE,
behindDoc: BooleanNumber.TRUE,
},
d2: {
id: 'd2',
docTransform: {
size: { width: 320, height: 180 },
positionH: { posOffset: 12 },
positionV: { posOffset: 34 },
angle: 15,
},
},
},
order: ['mask', 'photo', 'd2'],
});
expect(registerDrawingData).toHaveBeenCalledWith('doc-1', expect.any(Object));
expect(registerDrawingDataForManager).toHaveBeenCalledWith('doc-1', expect.any(Object));
const loadedDrawingData = registerDrawingDataForManager.mock.calls.at(-1)?.[1];
expect(loadedDrawingData).toMatchObject({
'doc-1': {
unitId: 'doc-1',
subUnitId: 'doc-1',
it('serializes snapshots and loads and unloads resources through drawing services', () => {
const bed = createFacadeTestBed();
try {
const model = bed.documentDataModel;
model.resetDrawing({ d1: drawing('d1') }, ['d1']);
const resources = bed.injector.get(IResourceManagerService);
const hook = resources.getAllResourceHooks().find((hook) => hook.pluginName === DOCS_DRAWING_PLUGIN)!;
expect(hook.businesses).toEqual([UniverInstanceType.UNIVER_DOC]);
expect(JSON.parse(hook.toJson('test-doc'))).toEqual({ data: model.getDrawings(), order: ['d1'] });
expect(hook.parseJson('')).toEqual({ data: {}, order: [] });
expect(hook.parseJson('{bad json')).toEqual({ data: {}, order: [] });
hook.onLoad('test-doc', {
data: {
mask: expect.objectContaining({ hidden: true }),
photo: expect.objectContaining({ hidden: true }),
d2: expect.objectContaining({
hidden: true,
docTransform: {
size: { width: 320, height: 180 },
positionH: { posOffset: 12 },
positionV: { posOffset: 34 },
angle: 15,
},
}),
mask: { ...drawing('mask'), behindDoc: BooleanNumber.FALSE },
photo: { ...drawing('photo'), behindDoc: BooleanNumber.TRUE },
d2: drawing('d2'),
},
order: ['photo', 'mask', 'd2'],
},
order: ['mask', 'photo', 'd2'],
});
const manager = bed.injector.get(IDrawingManagerService);
const docDrawings = bed.injector.get(IDocDrawingService);
expect(manager.getDrawingOrder('test-doc', 'test-doc')).toEqual(['photo', 'mask', 'd2']);
expect(docDrawings.getDrawingOrder('test-doc', 'test-doc')).toEqual(['mask', 'photo', 'd2']);
expect(manager.getDrawingData('test-doc', 'test-doc').d2).toMatchObject({
hidden: true,
docTransform: drawing('d2').docTransform,
});
expect(manager.getDrawingData('test-doc', 'test-doc').d2).not.toHaveProperty('transform');
expect(docDrawings.getDrawingData('test-doc', 'test-doc').d2).not.toHaveProperty('hidden');
hook.onUnLoad('test-doc');
expect(model.getDrawings()).toEqual({});
expect(model.getDrawingsOrder()).toEqual([]);
expect(manager.getDrawingData('test-doc', 'test-doc')).toEqual({});
expect(docDrawings.getDrawingData('test-doc', 'test-doc')).toEqual({});
} finally {
bed.univer.dispose();
}
});
it('normalizes imported body and footnote drawing identities without flattening persisted resources', () => {
const bed = createFacadeTestBed({
id: 'test-doc',
documentStyle: {},
body: { dataStream: '\r\n' },
drawings: { image: drawing('image') },
drawingsOrder: ['image'],
notes: { note: { type: 'footnote' as const, noteId: 'note', body: { dataStream: '\b\r\n' }, drawings: { 'note-image': { ...drawing('note-image'), layoutType: PositionedObjectLayoutType.INLINE } }, drawingsOrder: ['note-image'] } },
});
const loadedDocDrawingData = registerDrawingData.mock.calls.at(-1)?.[1];
expect(loadedDocDrawingData?.['doc-1']?.order).toEqual(['mask', 'photo', 'd2']);
expect(loadedDocDrawingData?.['doc-1']?.data?.d2).not.toHaveProperty('hidden');
expect(loadedDrawingData?.['doc-1']?.data?.d2).not.toHaveProperty('transform');
capturedResource.onUnLoad('doc-1');
expect(snapshot.drawings).toEqual({});
expect(snapshot.drawingsOrder).toEqual([]);
expect(removeDrawingDataForUnit).toHaveBeenCalledWith('doc-1');
expect(removeDrawingDataForUnitFromManager).toHaveBeenCalledWith('doc-1');
controller.dispose();
});
});
describe('DocDrawingController imported drawing identity regression', () => {
it('normalizes drawing unit identity when registering imported snapshot data', () => {
const registerDocDrawingData = vi.fn();
const registerRenderDrawingData = vi.fn();
const snapshot = {
drawings: {
shape1: {
unitId: 'doc-1',
subUnitId: 'stale-import-unit',
drawingId: 'shape1',
drawingType: 1,
},
},
drawingsOrder: ['shape1'],
};
const documentDataModel = {
getDrawings: () => snapshot.drawings,
getDrawingsOrder: () => snapshot.drawingsOrder,
};
const resourceManagerService = {
registerPluginResource: vi.fn(() => ({ dispose: vi.fn() })),
};
const controller = new DocDrawingController(
{ registerDrawingData: registerDocDrawingData } as never,
{ registerDrawingData: registerRenderDrawingData } as never,
resourceManagerService as never,
{
getUnit: vi.fn((_unitId: string, _type?: UniverInstanceType) => documentDataModel),
} as never,
{ registerCommand: vi.fn(() => ({ dispose: vi.fn() })) } as never
);
expect(controller.loadDrawingDataForUnit('doc-1')).toBe(true);
expect(registerDocDrawingData).toHaveBeenCalledWith('doc-1', expect.objectContaining({
'doc-1': expect.objectContaining({
data: {
shape1: expect.objectContaining({
unitId: 'doc-1',
subUnitId: 'doc-1',
}),
},
}),
}));
expect(registerRenderDrawingData).toHaveBeenCalledWith('doc-1', expect.objectContaining({
'doc-1': expect.objectContaining({
data: {
shape1: expect.objectContaining({
unitId: 'doc-1',
subUnitId: 'doc-1',
}),
},
}),
}));
controller.dispose();
try {
const controller = bed.injector.get(DocDrawingController);
expect(controller.loadDrawingDataForUnit('test-doc')).toBe(true);
for (const service of [bed.injector.get(IDocDrawingService), bed.injector.get(IDrawingManagerService)]) {
expect(service.getDrawingOrder('test-doc', 'test-doc')).toEqual(['image', 'note-image']);
expect(service.getDrawingData('test-doc', 'test-doc')).toMatchObject({
image: { unitId: 'test-doc', subUnitId: 'test-doc' },
'note-image': { unitId: 'test-doc', subUnitId: 'test-doc' },
});
}
expect(bed.documentDataModel.getDrawings()).not.toHaveProperty('note-image');
expect(bed.documentDataModel.getSnapshot().notes?.note.drawings?.['note-image'].subUnitId).toBe('stale-import-unit');
} finally {
bed.univer.dispose();
}
});
});
@@ -24,6 +24,7 @@ import { RemoveDocDrawingCommand } from '../commands/commands/remove-doc-drawing
import { SetDocDrawingArrangeCommand } from '../commands/commands/set-drawing-arrange.command';
import { UpdateDrawingDocTransformCommand } from '../commands/commands/update-doc-drawing-transform.command';
import { UpdateDocDrawingWrappingStyleCommand } from '../commands/commands/update-doc-drawing-wrapping-style.command';
import { collectDocDrawings } from '../services/doc-drawing-source';
import { IDocDrawingService } from '../services/doc-drawing.service';
export const DOCS_DRAWING_PLUGIN = 'DOC_DRAWING_PLUGIN';
@@ -140,8 +141,7 @@ export class DocDrawingController extends Disposable {
const subUnitId = unitId;
const drawingDataModels = dataModel.getDrawings();
const drawingOrderModel = dataModel.getDrawingsOrder();
const { drawings: drawingDataModels, drawingsOrder: drawingOrderModel } = collectDocDrawings(dataModel.getSnapshot());
if (!drawingDataModels || !drawingOrderModel) {
return false;
@@ -18,7 +18,9 @@ import type { IDocumentData, IObjectPositionH, IObjectPositionV } from '@univerj
import {
ArrangeTypeEnum,
BooleanNumber,
CustomRangeType,
DataStreamTreeTokenType,
DocumentFlavor,
ICommandService,
ImageSourceType,
ObjectRelativeFromH,
@@ -110,6 +112,60 @@ describe('FDocument image facade', () => {
expect(testBed.document.getImages().map((item) => item.getId())).toEqual([image!.getId()]);
});
it('inserts, resizes and removes an inline footnote image without changing body resources', async () => {
testBed.univer.dispose();
testBed = createFacadeTestBed({
id: 'test-doc',
documentStyle: { documentFlavor: DocumentFlavor.TRADITIONAL },
body: {
dataStream: 'A\uFFFC\r\n',
paragraphs: [{ startIndex: 2, paragraphId: 'p' }],
sectionBreaks: [{ startIndex: 3, sectionId: 's' }],
customRanges: [{
rangeId: 'reference',
rangeType: CustomRangeType.FOOTNOTE,
startIndex: 1,
endIndex: 1,
wholeEntity: true,
properties: { noteId: 'note' },
}],
},
notes: { note: { type: 'footnote' as const, noteId: 'note', body: {
dataStream: 'Note\r\n',
paragraphs: [{ startIndex: 4, paragraphId: 'np' }],
} } },
});
const before = testBed.document.save();
const image = await testBed.document.insertImage({
source: 'data:image/png;base64,image',
imageSourceType: ImageSourceType.BASE64,
width: 20,
height: 10,
wrappingStyle: TextWrappingStyle.WRAP_SQUARE,
textRange: { startOffset: 4, endOffset: 4, collapsed: true, segmentId: 'note' },
});
if (!image) {
throw new Error('Expected footnote image insertion');
}
expect(image.getImageData()?.layoutType).toBe(PositionedObjectLayoutType.INLINE);
expect(testBed.document.getImage(image.getId())?.getSource()).toBe('data:image/png;base64,image');
expect(testBed.document.getImages().map((item) => item.getId())).toEqual([image.getId()]);
expect(image.setSize(30, 15)).toBe(true);
expect(image.getSize()).toEqual({ width: 30, height: 15 });
expect(image.setWrappingStyle(TextWrappingStyle.WRAP_SQUARE)).toBe(false);
const inserted = testBed.document.save();
expect(inserted.notes?.note.body.dataStream).toBe('Note\b\r\n');
expect(inserted.notes?.note.drawingsOrder).toEqual([image.getId()]);
expect(inserted.drawings).toEqual(before.drawings);
expect(inserted.body).toEqual(before.body);
expect(image.remove()).toBe(true);
expect(testBed.document.save().notes?.note.body.dataStream).toBe('Note\r\n');
expect(testBed.document.save().notes?.note.drawingsOrder).toEqual([]);
expect(testBed.document.getImage(image.getId())).toBeNull();
expect(testBed.document.undo()).toBe(true);
expect(testBed.document.save().notes?.note).toEqual(inserted.notes?.note);
});
it('resolves the insertion range only once', async () => {
const selectionManager = testBed.injector.get(DocSelectionManagerService);
const getActiveTextRange = vi.spyOn(selectionManager, 'getActiveTextRange');
@@ -24,6 +24,7 @@ import {
} from '@univerjs/core';
import { FBase } from '@univerjs/core/facade';
import {
findDocDrawing,
RemoveDocDrawingCommand,
SetDocDrawingArrangeCommand,
UpdateDocDrawingWrappingStyleCommand,
@@ -193,7 +194,7 @@ export class FDocumentImage extends FBase {
* ```
*/
getImageData(): IDocImage | null {
const drawing = this._document.getDocumentDataModel().getDrawings()?.[this._imageId];
const drawing = findDocDrawing(this._document.getDocumentDataModel().getSnapshot(), this._imageId)?.drawing;
if (!drawing || drawing.drawingType !== DrawingTypeEnum.DRAWING_IMAGE) {
return null;
}
@@ -453,11 +454,12 @@ export class FDocumentImage extends FBase {
private _getTextRange(): ITextRangeParam | null {
const snapshot = this._document.getDocumentDataModel().getSnapshot();
const { body, headers = {}, footers = {} } = snapshot;
const { body, headers = {}, footers = {}, notes = {} } = snapshot;
const segments = [
{ segmentId: '', body },
...Object.entries(headers).map(([segmentId, header]) => ({ segmentId, body: header.body })),
...Object.entries(footers).map(([segmentId, footer]) => ({ segmentId, body: footer.body })),
...Object.entries(notes).map(([segmentId, note]) => ({ segmentId, body: note.body })),
];
for (const { segmentId, body } of segments) {
@@ -28,6 +28,8 @@ import {
} from '@univerjs/core';
import { buildDocTransform, docDrawingPositionToTransform } from '@univerjs/docs';
import {
collectDocDrawings,
findDocDrawing,
InsertDocDrawingCommand,
TextWrappingStyle,
WRAPPING_STYLE_TO_LAYOUT_TYPE,
@@ -195,7 +197,7 @@ export class FDocumentImageMixin extends FDocument implements IFDocumentImageMix
}
override getImage(imageId: string): FDocumentImage | null {
const drawing = this.getDocumentDataModel().getDrawings()?.[imageId];
const drawing = findDocDrawing(this.getDocumentDataModel().getSnapshot(), imageId)?.drawing;
if (!drawing || drawing.drawingType !== DrawingTypeEnum.DRAWING_IMAGE) {
return null;
}
@@ -204,8 +206,7 @@ export class FDocumentImageMixin extends FDocument implements IFDocumentImageMix
override getImages(): FDocumentImage[] {
const documentDataModel = this.getDocumentDataModel();
const drawings = documentDataModel.getDrawings() ?? {};
const drawingIds = documentDataModel.getDrawingsOrder() ?? Object.keys(drawings);
const { drawings, drawingsOrder: drawingIds } = collectDocDrawings(documentDataModel.getSnapshot());
return drawingIds
.filter((drawingId) => drawings[drawingId]?.drawingType === DrawingTypeEnum.DRAWING_IMAGE)
.map((drawingId) => this._injector.createInstance(FDocumentImage, this, drawingId, this._injector));
+2
View File
@@ -35,6 +35,8 @@ export type {
IDocDrawingMutationInfos,
IDocDrawingRemoveMutationInfoParams,
} from './services/doc-drawing-adapter.service';
export { collectDocDrawings, findDocDrawing } from './services/doc-drawing-source';
export { DocDrawingService, IDocDrawingService } from './services/doc-drawing.service';
export type { IDocDrawing, IDocImage } from './services/doc-drawing.service';
export { type IDocFloatDom } from './services/doc-drawing.service';
@@ -0,0 +1,55 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { IDocumentData, IDrawings } from '@univerjs/core';
/** Resolve persisted drawing ownership independently of the current text selection. */
export function findDocDrawing(snapshot: IDocumentData, drawingId: string): {
drawing: IDrawings[string];
path: string[];
segmentId: string;
} | undefined {
const drawing = snapshot.drawings?.[drawingId];
if (drawing) {
return { drawing, path: ['drawings', drawingId], segmentId: '' };
}
for (const [segmentId, note] of Object.entries(snapshot.notes ?? {})) {
const drawing = note.drawings?.[drawingId];
if (drawing) {
return { drawing, path: ['notes', segmentId, 'drawings', drawingId], segmentId };
}
}
}
/** The render registry is flat; persisted note resources retain their own scope. */
export function collectDocDrawings(snapshot: IDocumentData): { drawings: IDrawings; drawingsOrder: string[] } {
let drawings = snapshot.drawings ?? {};
let drawingsOrder = snapshot.drawingsOrder ?? Object.keys(drawings);
let copied = false;
for (const note of Object.values(snapshot.notes ?? {})) {
if (!note.drawings || Object.keys(note.drawings).length === 0) {
continue;
}
if (!copied) {
drawings = { ...drawings };
drawingsOrder = [...drawingsOrder];
copied = true;
}
Object.assign(drawings, note.drawings);
drawingsOrder.push(...(note.drawingsOrder ?? Object.keys(note.drawings)));
}
return { drawings, drawingsOrder };
}
@@ -14,13 +14,68 @@
* limitations under the License.
*/
import type { DocumentDataModel, ICommand, IDocumentData, Injector, Univer } from '@univerjs/core';
import { awaitTime, DataStreamTreeTokenType, DocumentBlockRangeType, DocumentFlavor, ICommandService, IUniverInstanceService, NamedStyleType, PresetListType, UniverInstanceType } from '@univerjs/core';
import { DocSelectionManagerService, RichTextEditingMutation, SetTextSelectionsOperation } from '@univerjs/docs';
import type { DocumentDataModel, ICommand, IDocumentData, Injector } from '@univerjs/core';
import { awaitTime, CustomRangeType, DataStreamTreeTokenType, DocumentBlockRangeType, DocumentFlavor, ICommandService, IUniverInstanceService, NamedStyleType, PresetListType, Univer, UniverInstanceType } from '@univerjs/core';
import { DocSelectionManagerService, DocStateChangeManagerService, DocStateEmitService, InsertTextCommand, RichTextEditingMutation, SetTextSelectionsOperation } from '@univerjs/docs';
import { IRenderManagerService, NORMAL_TEXT_SELECTION_PLUGIN_STYLE, RenderManagerService } from '@univerjs/engine-render';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { DocMenuStyleService } from '../../../services/doc-menu-style.service';
import { BreakLineCommand, BreakLineInsertionMode } from '../break-line.command';
import { createCommandTestBed } from './create-command-test-bed';
it('keeps typing in the footnote after inserting a paragraph', async () => {
const univer = new Univer();
const injector = univer.__getInjector();
injector.add([IRenderManagerService, { useClass: RenderManagerService }]);
injector.add([DocSelectionManagerService]);
injector.add([DocStateEmitService]);
injector.add([DocStateChangeManagerService]);
injector.add([DocMenuStyleService]);
injector.get(DocStateChangeManagerService);
const doc = univer.createUnit<IDocumentData, DocumentDataModel>(UniverInstanceType.UNIVER_DOC, {
id: 'note-input',
documentStyle: { documentFlavor: DocumentFlavor.TRADITIONAL },
body: { dataStream: 'Body\uFFFC\r\n', paragraphs: [{ paragraphId: 'p', startIndex: 5 }], sectionBreaks: [{ sectionId: 's', startIndex: 6 }], customRanges: [{ rangeId: 'reference', rangeType: CustomRangeType.FOOTNOTE, startIndex: 4, endIndex: 4, properties: { noteId: 'note' } }] },
notes: { note: { type: 'footnote' as const, noteId: 'note', body: { dataStream: '\r\n', paragraphs: [{ paragraphId: 'np', startIndex: 0 }] } } },
});
injector.get(IUniverInstanceService).focusUnit(doc.getUnitId());
const commands = injector.get(ICommandService);
[RichTextEditingMutation, InsertTextCommand, BreakLineCommand]
.forEach((command) => commands.registerCommand(command));
const selections = injector.get(DocSelectionManagerService);
selections.__TEST_ONLY_setCurrentSelection({ unitId: 'note-input', subUnitId: 'note-input' });
selections.replaceSelectionInfoWithoutRefresh({
textRanges: [{ startOffset: 0, endOffset: 0, collapsed: true, isActive: true, segmentId: 'note' }],
rectRanges: [],
segmentId: '',
segmentPage: -1,
isEditing: true,
style: NORMAL_TEXT_SELECTION_PLUGIN_STYLE,
});
try {
const noteId = Object.keys(doc.getSnapshot().notes!)[0];
for (const text of ['First', ' ', 'line', '\r', 'Next', ' ', 'line']) {
const range = selections.getActiveTextRange()!;
expect(range.segmentId).toBe(noteId);
if (text === '\r') {
expect(commands.syncExecuteCommand(BreakLineCommand.id)).toBe(true);
} else {
expect(commands.syncExecuteCommand(InsertTextCommand.id, {
unitId: doc.getUnitId(),
range,
body: { dataStream: text },
})).toBe(true);
}
await Promise.resolve();
}
expect(selections.getActiveTextRange()).toMatchObject({ segmentId: noteId, startOffset: 20 });
expect(doc.getBody()?.dataStream).toBe('Body\uFFFC\r\n');
expect(doc.getSnapshot().notes?.[noteId].body.dataStream).toBe('First line\rNext line\r\n');
} finally {
univer.dispose();
}
});
function getDocumentData(): IDocumentData {
return {
id: 'test-doc',
@@ -0,0 +1,122 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { DocumentDataModel, IDocumentData } from '@univerjs/core';
import { CustomRangeType, DocumentFlavor, ICommandService, IUniverInstanceService, RedoCommand, UndoCommand, Univer, UniverInstanceType, validateDocumentStructure } from '@univerjs/core';
import { DocSelectionManagerService, DocStateChangeManagerService, DocStateEmitService, RichTextEditingMutation } from '@univerjs/docs';
import { IRenderManagerService, NORMAL_TEXT_SELECTION_PLUGIN_STYLE, RenderManagerService } from '@univerjs/engine-render';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { createInternalClipboardDocData } from '../../../services/clipboard/internal-fragment';
import { InnerPasteCommand } from '../clipboard.inner.command';
const SOURCE: IDocumentData = {
id: 'source',
documentStyle: { documentFlavor: DocumentFlavor.TRADITIONAL },
body: {
dataStream: 'A\uFFFCB',
textRuns: [{ st: 2, ed: 3, ts: { bl: 1 } }],
customRanges: [{ startIndex: 1, endIndex: 1, wholeEntity: true, rangeId: 'reference', rangeType: CustomRangeType.FOOTNOTE, properties: { noteId: 'note' } }],
},
notes: {
note: { type: 'footnote' as const, noteId: 'note', customMark: '†', referenceTextStyle: { fs: 8, va: 3 }, body: {
dataStream: 'Explanation\r\n',
paragraphs: [{ startIndex: 11, paragraphId: 'source-paragraph' }],
sectionBreaks: [{ startIndex: 12, sectionId: 'source-section' }],
textRuns: [{ st: 0, ed: 11, ts: { fs: 9.5 } }],
} },
},
};
describe('InnerPasteCommand notes', () => {
let univer: Univer;
let document: DocumentDataModel;
let commands: ICommandService;
let selections: DocSelectionManagerService;
beforeEach(() => {
univer = new Univer();
const injector = univer.__getInjector();
injector.add([IRenderManagerService, { useClass: RenderManagerService }]);
injector.add([DocSelectionManagerService]);
injector.add([DocStateEmitService]);
injector.add([DocStateChangeManagerService]);
injector.get(DocStateChangeManagerService);
commands = injector.get(ICommandService);
[InnerPasteCommand, RichTextEditingMutation].forEach((command) => commands.registerCommand(command));
document = univer.createUnit<IDocumentData, DocumentDataModel>(UniverInstanceType.UNIVER_DOC, {
id: 'target',
documentStyle: { documentFlavor: DocumentFlavor.TRADITIONAL },
body: { dataStream: '\r\n', customRanges: [], customDecorations: [], customBlocks: [], textRuns: [], paragraphs: [{ startIndex: 0, paragraphId: 'target-paragraph' }], sectionBreaks: [{ startIndex: 1, sectionId: 'target-section' }] },
});
injector.get(IUniverInstanceService).focusUnit(document.getUnitId());
selections = injector.get(DocSelectionManagerService);
selections.__TEST_ONLY_setCurrentSelection({ unitId: 'target', subUnitId: 'target' });
});
afterEach(() => univer.dispose());
async function paste(offset: number, segmentId = '') {
selections.replaceSelectionInfoWithoutRefresh({
textRanges: [{ startOffset: offset, endOffset: offset, segmentId, collapsed: true, isActive: true }],
rectRanges: [],
segmentId,
segmentPage: -1,
isEditing: true,
style: NORMAL_TEXT_SELECTION_PLUGIN_STYLE,
});
return commands.executeCommand(InnerPasteCommand.id, {
doc: createInternalClipboardDocData(SOURCE),
segmentId,
textRanges: [{ startOffset: offset + 3, endOffset: offset + 3, collapsed: true, segmentId }],
});
}
it('copies independent rich notes and restores their identities through undo and redo', async () => {
const before = structuredClone(document.getSnapshot());
expect(await paste(0)).toBe(true);
const first = structuredClone(document.getSnapshot());
const firstId = Object.keys(first.notes!)[0];
expect(firstId).not.toBe('note');
expect(first.notes![firstId]).toMatchObject({ customMark: '†', referenceTextStyle: { fs: 8, va: 3 } });
expect(first.notes![firstId].body.textRuns).toEqual(SOURCE.notes!.note.body.textRuns);
commands.syncExecuteCommand(UndoCommand.id);
expect(document.getSnapshot()).toEqual(before);
commands.syncExecuteCommand(RedoCommand.id);
expect(document.getSnapshot()).toEqual(first);
expect(await paste(3)).toBe(true);
const second = document.getSnapshot();
const ids = second.body!.customRanges!.map((range) => range.properties!.noteId);
expect(new Set(ids).size).toBe(2);
expect(Object.keys(second.notes!)).toHaveLength(2);
expect(validateDocumentStructure(second)).toEqual([]);
expect(SOURCE.notes!.note.body.paragraphs![0].paragraphId).toBe('source-paragraph');
});
it('drops references in modern mode and adjusts following text styles', async () => {
document.updateDocumentStyle({ documentFlavor: DocumentFlavor.MODERN });
expect(await paste(0)).toBe(true);
expect(document.getBody()!.dataStream).toBe('AB\r\n');
expect(document.getBody()!.textRuns).toContainEqual(expect.objectContaining({ st: 1, ed: 2, ts: { bl: 1 } }));
expect(document.getSnapshot().notes).toEqual({});
expect(validateDocumentStructure(document.getSnapshot())).toEqual([]);
});
it('rejects nested references without leaving orphan note resources', async () => {
await paste(0);
const before = structuredClone(document.getSnapshot());
const id = Object.keys(before.notes!)[0];
expect(await paste(0, id)).toBe(false);
expect(document.getSnapshot()).toEqual(before);
});
});
@@ -0,0 +1,65 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { DocumentDataModel, IDocumentData } from '@univerjs/core';
import { DeleteDirection, DocumentFlavor, ICommandService, IUniverInstanceService, Univer, UniverInstanceType } from '@univerjs/core';
import { DocSelectionManagerService, DocStateEmitService, RichTextEditingMutation } from '@univerjs/docs';
import { IRenderManagerService, NORMAL_TEXT_SELECTION_PLUGIN_STYLE, RenderManagerService } from '@univerjs/engine-render';
import { describe, expect, it } from 'vitest';
import { MergeTwoParagraphCommand } from '../doc-delete.command';
describe('MergeTwoParagraphCommand segment selection', () => {
it.each([DeleteDirection.LEFT, DeleteDirection.RIGHT])('keeps the footnote caret after merging in direction %s', async (direction) => {
const univer = new Univer();
const injector = univer.__getInjector();
injector.add([IRenderManagerService, { useClass: RenderManagerService }]);
injector.add([DocSelectionManagerService]);
injector.add([DocStateEmitService]);
const model = univer.createUnit<IDocumentData, DocumentDataModel>(UniverInstanceType.UNIVER_DOC, {
id: 'note-merge',
documentStyle: { documentFlavor: DocumentFlavor.TRADITIONAL },
body: { dataStream: 'Body\r\n', paragraphs: [{ startIndex: 4, paragraphId: 'body' }] },
notes: { note: { type: 'footnote' as const, noteId: 'note', body: {
dataStream: 'First\rSecond\r\n',
paragraphs: [{ startIndex: 5, paragraphId: 'first' }, { startIndex: 12, paragraphId: 'second' }],
} } },
});
try {
injector.get(IUniverInstanceService).focusUnit('note-merge');
const commands = injector.get(ICommandService);
commands.registerCommand(MergeTwoParagraphCommand);
commands.registerCommand(RichTextEditingMutation);
const selections = injector.get(DocSelectionManagerService);
selections.__TEST_ONLY_setCurrentSelection({ unitId: 'note-merge', subUnitId: 'note-merge' });
const offset = direction === DeleteDirection.LEFT ? 6 : 5;
const range = { startOffset: offset, endOffset: offset, collapsed: true, isActive: true, segmentId: 'note' };
selections.replaceSelectionInfoWithoutRefresh({
textRanges: [range],
rectRanges: [],
segmentId: 'note',
segmentPage: 2,
isEditing: true,
style: NORMAL_TEXT_SELECTION_PLUGIN_STYLE,
});
expect(await commands.executeCommand(MergeTwoParagraphCommand.id, { direction, range })).toBe(true);
expect(model.getSnapshot().notes!.note.body.dataStream).toBe('FirstSecond\r\n');
expect(model.getSnapshot().body!.dataStream).toBe('Body\r\n');
expect(selections.getActiveTextRange()).toMatchObject({ segmentId: 'note', startOffset: 5, endOffset: 5 });
} finally {
univer.dispose();
}
});
});
@@ -220,9 +220,11 @@ export const BreakLineCommand: ICommand<IBreakLineCommandParams> = {
startOffset: startOffset + cursorMove,
endOffset: startOffset + cursorMove,
collapsed,
segmentId,
}];
const path = getRichTextEditPath(docDataModel, segmentId);
doMutation.params.segmentId = segmentId;
doMutation.params.actions = jsonX.editOp(textX.serialize(), path);
const result = commandService.syncExecuteCommand<
IRichTextEditingMutationParams,
@@ -23,6 +23,7 @@ import {
CommandType,
createParagraphId,
DataStreamTreeTokenType,
DocumentFlavor,
generateRandomId,
getCustomBlockIdsInSelections,
getRichTextEditPath,
@@ -43,6 +44,7 @@ import { getCustomDecorationAtPosition, getCustomRangeAtPosition } from '../../b
import {
IDocClipboardPasteAdapterService,
} from '../../services/clipboard/doc-paste-mutation-adapter.service';
import { cloneClipboardNotes, omitClipboardNotes } from '../../services/clipboard/internal-fragment';
import { getCommandSkeleton } from '../util';
import { getDeleteRowContentActionParams, getDeleteRowsActionsParams, getDeleteTableActionParams } from './table/table';
@@ -83,7 +85,8 @@ export const InnerPasteCommand: ICommand<IInnerPasteCommandParams> = {
const selections = docSelectionManagerService.getTextRanges() ?? [];
const rectRanges = docSelectionManagerService.getRectRanges() ?? [];
const selectionInfo = docSelectionManagerService.getSelectionInfo();
const { body, tableSource, drawings } = doc;
const { body: sourceBody, tableSource, drawings } = doc;
let body = sourceBody;
if ((selections.length === 0 && rectRanges.length === 0) || body == null) {
return false;
}
@@ -94,6 +97,9 @@ export const InnerPasteCommand: ICommand<IInnerPasteCommandParams> = {
return false;
}
const unitId = docDataModel.getUnitId();
if (docDataModel.getDocumentStyle().documentFlavor !== DocumentFlavor.TRADITIONAL) {
body = omitClipboardNotes(body);
}
const doMutation: IMutationInfo<IRichTextEditingMutationParams> = {
id: RichTextEditingMutation.id,
@@ -112,6 +118,7 @@ export const InnerPasteCommand: ICommand<IInnerPasteCommandParams> = {
const textX = new TextX();
const jsonX = JSONX.getInstance();
const rawActions: JSONXActions = [];
const pastedNotes: NonNullable<IDocumentData['notes']> = {};
const resourceRedoMutations: IMutationInfo[] = [];
const resourceUndoMutations: IMutationInfo[] = [];
const resourceMutationGroups: Array<{ redoMutations: IMutationInfo[]; undoMutations: IMutationInfo[] }> = [];
@@ -179,15 +186,18 @@ export const InnerPasteCommand: ICommand<IInnerPasteCommandParams> = {
const cloneBody = Tools.deepClone(body);
const blockRangeMappings: IDocClipboardPasteBlockRangeMapping[] = [];
const customBlockMappings: IDocClipboardPasteCustomBlockMapping[] = [];
const selectionCustomRangeMappings = customRangeMappings.map(({ sourceRange }) => ({
sourceRange,
targetRange: BuildTextUtils.customRange.copyCustomRange(sourceRange),
const selectionCustomRangeMappings = (body.customRanges ?? []).map((range) => ({
sourceRange: customRangeMappings.find(({ targetRange }) => targetRange.rangeId === range.rangeId)?.sourceRange ?? range,
targetRange: BuildTextUtils.customRange.copyCustomRange(range),
}));
if (selectionCustomRangeMappings.length > 0) {
cloneBody.customRanges = selectionCustomRangeMappings.map(
({ targetRange }) => targetRange
);
}
if (docDataModel.getDocumentStyle().documentFlavor === DocumentFlavor.TRADITIONAL) {
Object.assign(pastedNotes, cloneClipboardNotes(cloneBody, doc.notes));
}
if (hasBlockRange) {
cloneBody.blockRanges!.forEach((targetBlockRange, index) => {
@@ -307,6 +317,20 @@ export const InnerPasteCommand: ICommand<IInnerPasteCommandParams> = {
const path = getRichTextEditPath(docDataModel, segmentId);
if (Object.keys(pastedNotes).length > 0) {
if (segmentId) {
return false;
}
const existing = docDataModel.getSnapshot().notes;
if (existing == null) {
rawActions.push(jsonX.insertOp(['notes'], pastedNotes)!);
} else {
for (const [id, note] of Object.entries(pastedNotes)) {
rawActions.push(jsonX.insertOp(['notes', id], note)!);
}
}
}
rawActions.push(jsonX.editOp(textX.serialize(), path)!);
const pasteActions = rawActions.reduce((acc, cur) => {
@@ -210,6 +210,7 @@ export const MergeTwoParagraphCommand: ICommand<IMergeTwoParagraphParams> = {
id: RichTextEditingMutation.id,
params: {
unitId,
segmentId,
actions: [],
textRanges,
prevTextRanges: [range],
@@ -15,11 +15,32 @@
*/
import type { IDocumentData } from '@univerjs/core';
import { JSONX } from '@univerjs/core';
import { CustomRangeType, JSONX, TextX } from '@univerjs/core';
import { describe, expect, it } from 'vitest';
import { getDocumentMutationLayoutImpact } from '../render-controllers/doc-mutation-layout';
describe('document mutation layout range index', () => {
it.each(['first-note', 'another-note', 'edit-note'])('starts %s layout at the body reference instead of the start of the document', (operation) => {
const note = { type: 'footnote' as const, noteId: 'note', body: { dataStream: 'Explanation\r\n' } };
const jsonX = JSONX.getInstance();
let actions = jsonX.insertOp(['notes'], { note });
if (operation === 'another-note') {
actions = jsonX.insertOp(['notes', 'note'], note);
} else if (operation === 'edit-note') {
actions = jsonX.editOp(new TextX().insert(1, { dataStream: 'X' }).serialize(), ['notes', 'note', 'body']);
}
const impact = getDocumentMutationLayoutImpact(actions, {
body: { dataStream: '', customRanges: [{
rangeId: 'ref',
rangeType: CustomRangeType.FOOTNOTE,
startIndex: 90_000,
endIndex: 90_000,
properties: { noteId: 'note' },
}] },
});
expect(impact).toEqual({ global: false, range: { start: 90_000, end: 90_001 }, unresolvedLocal: false });
});
it('indexes table ranges once for a composed table mutation', () => {
const tableCount = 1_000;
let indexedReads = 0;
@@ -203,4 +203,17 @@ describe('document layout progress', () => {
}))).toBe(25);
expect(resolveDocLayoutProgressPercent(createProgress({ complete: true }))).toBe(100);
});
it('does not report the initial interaction window as a completed document', () => {
const handoff = createProgress({
processedBlockCount: 0,
totalBlockCount: 6_264,
pageCount: 2,
publishedPageCount: 2,
estimatedPageCount: 2,
});
expect(resolveDocLayoutProgressPercent(handoff)).toBe(0);
expect(resolveDocLayoutProgressPercent({ ...handoff, processedBlockCount: 64, estimatedPageCount: 314 })).toBe(1);
expect(resolveDocLayoutProgressPercent({ ...handoff, processedBlockCount: 3_132, publishedPageCount: 140, estimatedPageCount: 314 })).toBe(50);
});
});
@@ -21,6 +21,7 @@ import type { IDocLayoutExecutor } from '@univerjs/docs';
import type { Documents, IPointerEvent, RenderUnit } from '@univerjs/engine-render';
import {
BooleanNumber,
CustomRangeType,
DataStreamTreeTokenType,
DocumentFlavor,
DrawingTypeEnum,
@@ -69,7 +70,7 @@ import { DocInputController } from '../doc-input.controller';
import { DocSelectionRenderController } from '../doc-selection-render.controller';
import { DocRenderController } from '../doc.render-controller';
function createEditor(paragraphCount = 8, withDrawing = true, workerBeforeLayout = false, documentFlavor = DocumentFlavor.TRADITIONAL) {
function createEditor(paragraphCount = 8, withDrawing = true, workerBeforeLayout = false, documentFlavor = DocumentFlavor.TRADITIONAL, withFootnote = false) {
const univer = new Univer();
const injector = univer.__getInjector();
const root = document.createElement('div');
@@ -100,8 +101,9 @@ function createEditor(paragraphCount = 8, withDrawing = true, workerBeforeLayout
.forEach((command) => commands.registerCommand(command));
const firstParagraph = 'Hello world';
const drawingToken = withDrawing ? DataStreamTreeTokenType.CUSTOM_BLOCK : '';
const noteStream = `${'Footnote continuation with several words.\r'.repeat(40)}\n`;
const separator = documentFlavor === DocumentFlavor.MODERN ? '\r\r' : '\r';
const dataStream = `${firstParagraph}\r${drawingToken}${Array.from({ length: paragraphCount }, (_, i) => `Paragraph ${i} has enough words to wrap across several lines.${separator}`).join('')}\n`;
const dataStream = `${firstParagraph}\r${drawingToken}${withFootnote ? '\uFFFC' : ''}${Array.from({ length: paragraphCount }, (_, i) => `Paragraph ${i} has enough words to wrap across several lines.${separator}`).join('')}\n`;
const model = univer.createUnit<IDocumentData, DocumentDataModel>(UniverInstanceType.UNIVER_DOC, {
id: 'bounded-caret-test',
// A drawing on the edited page deliberately keeps the conservative
@@ -123,8 +125,24 @@ function createEditor(paragraphCount = 8, withDrawing = true, workerBeforeLayout
},
}
: {},
notes: withFootnote
? { note: { type: 'footnote' as const, noteId: 'note', body: {
dataStream: noteStream,
paragraphs: [...noteStream.matchAll(/\r/g)].map((match, index) => ({ startIndex: match.index!, paragraphId: `note-${index}` })),
} } }
: undefined,
body: {
dataStream,
customRanges: withFootnote
? [{
rangeType: CustomRangeType.FOOTNOTE,
rangeId: 'note-ref',
wholeEntity: true,
startIndex: 12,
endIndex: 12,
properties: { noteId: 'note' },
}]
: [],
customBlocks: withDrawing ? [{ blockId: 'inline-drawing', startIndex: firstParagraph.length + 1 }] : [],
paragraphs: [...dataStream.matchAll(/\r/g)].map((match, index) => ({
startIndex: match.index!,
@@ -220,6 +238,53 @@ describe('DocRenderController bounded input publication', () => {
vi.useRealTimers();
});
it('keeps typing at the logical footnote caret after Enter moves it to a continuation page', async () => {
const editor = createEditor(8, false, false, DocumentFlavor.TRADITIONAL, true);
try {
await vi.dynamicImportSettled();
const pages = editor.skeleton.getSkeletonData()!.pages;
const oldPage = pages.findIndex((page) => page.notes?.some((note) => note.noteId === 'note'));
const boundary = pages[oldPage].notes![0].page.ed;
editor.selectionManager.replaceDocRanges([{
startOffset: boundary,
endOffset: boundary,
segmentId: 'note',
segmentPage: oldPage,
}], { unitId: editor.unitId, subUnitId: editor.unitId }, true);
expect(await editor.commands.executeCommand(BreakLineCommand.id)).toBe(true);
for (const text of ['A', ' ', '中', 'B']) {
editor.input.textContent = text;
editor.input.dispatchEvent(new InputEvent('input', { data: text, inputType: 'insertText' }));
await Promise.resolve();
await Promise.resolve();
}
await vi.advanceTimersByTimeAsync(1_000);
const offset = boundary + 5;
expect(editor.model.getSnapshot().notes!.note.body.dataStream.slice(boundary, offset)).toBe('\rA 中B');
expect(editor.selectionManager.getActiveTextRange()?.endOffset).toBe(offset);
const position = editor.skeleton.findNodePositionByCharIndex(offset, true, 'note');
expect(position?.page).toBeGreaterThan(oldPage);
expect(editor.selection.getActiveTextRange()?.endOffset).toBe(offset);
expect(editor.selection.getSegmentPage()).toBe(position?.page);
expect(editor.selection.hasPendingSelection).toBe(false);
editor.selectionManager.replaceDocRanges([{
startOffset: 5,
endOffset: 5,
segmentId: '',
segmentPage: -1,
}], { unitId: editor.unitId, subUnitId: editor.unitId }, true);
expect(editor.selection.getSegment()).toBe('');
editor.input.textContent = 'Z';
editor.input.dispatchEvent(new InputEvent('input', { data: 'Z', inputType: 'insertText' }));
await Promise.resolve();
await Promise.resolve();
expect(editor.model.getBody()!.dataStream.startsWith('HelloZ world')).toBe(true);
expect(editor.model.getSnapshot().notes!.note.body.dataStream.slice(boundary, offset)).toBe('\rA 中B');
} finally {
editor.dispose();
}
});
it('keeps short Modern edits and spaces complete while Worker initialization is pending', async () => {
const editor = createEditor(3, false, false, DocumentFlavor.MODERN);
try {
@@ -39,7 +39,12 @@ export function resolveDocLayoutProgressPercent(progress: IDocumentLayoutProgres
const pageProgress = progress.mode === 'paginated' && estimatedPageCount > 0
? progress.publishedPageCount / estimatedPageCount
: 0;
return Math.min(99, Math.max(0, Math.floor(Math.max(blockProgress, pageProgress) * 100)));
// Before the first Worker slice, the page estimate only contains the Main
// interaction window. Publishing that window does not complete initial layout.
const completedFraction = progress.reason === 'initial' && progress.totalBlockCount > 0
? blockProgress
: Math.max(blockProgress, pageProgress);
return Math.min(99, Math.max(0, Math.floor(completedFraction * 100)));
}
export class DocLayoutProgressRenderController extends RxDisposable implements IRenderModule {
@@ -16,7 +16,7 @@
import type { IDocStyles, IDocumentBody, IDocumentData, JSONXActions } from '@univerjs/core';
import type { IDocumentLayoutInvalidation } from '@univerjs/engine-render';
import { JSON1, TextX } from '@univerjs/core';
import { CustomRangeType, JSON1, TextX } from '@univerjs/core';
interface IDocumentLayoutRange {
end: number;
@@ -226,6 +226,21 @@ function getComponentLayoutImpact(
if (root === 'body') {
return getBodyComponentLayoutImpact(path, component, body);
}
if (root === 'notes') {
const ids = getChangedRecordKeys(path, component);
let range: IDocumentLayoutRange | undefined;
for (const reference of body?.customRanges ?? []) {
const id = reference.properties?.noteId;
if ((reference.rangeType === CustomRangeType.FOOTNOTE || reference.rangeType === CustomRangeType.ENDNOTE) && typeof id === 'string' && ids.has(id)) {
range = mergeLayoutRange(range, { start: reference.startIndex, end: reference.endIndex + 1 });
}
}
return {
global: false,
range,
unresolvedLocal: range == null,
};
}
if (root === 'tableSource') {
const tableId = path[1];
const range = typeof tableId === 'string' ? index.getTableRange(tableId) : undefined;
@@ -997,11 +997,14 @@ export class DocRenderController extends RxDisposable implements IRenderModule {
);
} else if (isInitialLayout) {
this._refreshPagePosition();
} else if (progress.didPublishAnchor && (
} else if ((progress.didPublishAnchor && (
refreshIncompleteAnchorSelection || this._docSelectionRenderService.hasPendingSelection
)) {
)) || (publication != null &&
this._context.unit.getSnapshot().notes?.[this._getActiveRange(unitId)?.segmentId ?? ''] != null)) {
// The foreground pass replaces edited line and glyph objects. Rebuild
// the caret from stable document offsets without moving the viewport.
// A footnote can continue beyond the body anchor, so later Worker
// publications must also resolve its current segment offset.
this._textSelectionManagerService.refreshSelection(
{ unitId, subUnitId: unitId },
this._getActiveEditingRange(unitId) != null
@@ -1728,7 +1731,7 @@ export class DocRenderController extends RxDisposable implements IRenderModule {
if (!doesDocMutationRequireLayout(params.actions, snapshot.drawings)) {
return;
}
const bodyRanges = textRanges?.filter((range) => !range.segmentId) ?? [];
const bodyRanges = textRanges?.filter((range) => !(range.segmentId ?? params.segmentId)) ?? [];
const textInvalidation = getBodyMutationInvalidation(params.actions, params.segmentId);
const mutationLayoutImpact = getDocumentMutationLayoutImpact(
params.actions,
@@ -1754,7 +1757,8 @@ export class DocRenderController extends RxDisposable implements IRenderModule {
? undefined
: bodyRanges.find((range) => range.isActive) ??
(bodyRanges.length === 1 ? bodyRanges[0] : undefined);
const priorityAnchor = mutationActiveRange?.endOffset ?? activeRange?.endOffset;
const priorityAnchor = mutationActiveRange?.endOffset ??
(activeRange?.segmentId ? undefined : activeRange?.endOffset);
// RichTextEditingMutation preserves the original Main input
// contract by refreshing its local post-edit range in a microtask,
// once the synchronous layout prefix has finished. Do not publish
-1
View File
@@ -119,7 +119,6 @@ import {
DOC_SECTION_BREAK_NEXT_COLUMN_MENU_ID,
DOC_SECTION_BREAK_NEXT_PAGE_MENU_ID,
DOC_SECTION_BREAK_ODD_PAGE_MENU_ID,
// DocSwitchModeMenuItemFactory,
EmojiPickerMenuItemFactory,
FLOAT_TEXT_STYLE_MENU_ID,
FLOAT_TOOLBAR_MENU_POSITION,
@@ -34,9 +34,10 @@ import {
Documents,
ICanvasColorService,
IRenderManagerService,
Rect,
RenderManagerService,
} from '@univerjs/engine-render';
import { ICanvasPopupService } from '@univerjs/ui';
import { CanvasPopupService, ICanvasPopupService } from '@univerjs/ui';
import { describe, expect, it, vi } from 'vitest';
import { SetDocZoomRatioOperation } from '../../commands/operations/set-doc-zoom-ratio.operation';
import {
@@ -54,6 +55,60 @@ import {
import { NodePositionConvertToCursor } from '../selection/convert-text-range';
describe('popup anchors with real document layout', () => {
it('lets an object toolbar follow layout transforms without holding a document layout lock', () => {
const context = new Proxy({}, { get: () => () => {} });
vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue(context as never);
const univer = new Univer();
try {
const injector = univer.__getInjector();
injector.add([IRenderManagerService, { useClass: RenderManagerService }]);
injector.add([ICanvasColorService, { useClass: CanvasColorService }]);
injector.add([ICanvasPopupService, { useClass: CanvasPopupService }]);
injector.add([DocCanvasPopManagerService]);
const model = univer.createUnit<IDocumentData, DocumentDataModel>(UniverInstanceType.UNIVER_DOC, {
id: 'object-popup-test',
body: { dataStream: '\r\n' },
});
const render = injector.get(IRenderManagerService).createRender(model.getUnitId()) as RenderUnit;
render.deactivate();
render.engine.resizeBySize(300, 400);
const canvas = render.engine.getCanvasElement()!;
canvas.style.width = '300px';
vi.spyOn(canvas, 'getBoundingClientRect').mockReturnValue(new DOMRect(0, 0, 300, 400));
render.addRenderDependencies([[DocLayoutInteractionService], [DocCanvasPopupLayoutInteractionController]]);
render.with(DocCanvasPopupLayoutInteractionController);
const interaction = render.with(DocLayoutInteractionService);
const shape = new Rect('note-image', { left: 20, top: 300, width: 100, height: 40 });
render.scene.addObject(shape);
const popupManager = injector.get(DocCanvasPopManagerService);
const popup = popupManager.attachPopupToObject(shape, {
componentKey: 'object-toolbar',
requiresStableLayout: false,
}, model.getUnitId());
const popupService = injector.get(ICanvasPopupService);
const anchors: unknown[] = [];
let completed = false;
const subscription = popupService.popups[0][1].anchorRect$.subscribe({
next: (anchor) => anchors.push(anchor),
complete: () => { completed = true; },
});
expect(interaction.isActive).toBe(false);
shape.transformByState({ top: 280, width: 150, height: 60 });
expect(anchors[anchors.length - 1]).toMatchObject({ left: 20, right: 170, top: 280, bottom: 340 });
expect(interaction.isActive).toBe(false);
popup.dispose();
expect(completed).toBe(true);
const count = anchors.length;
shape.transformByState({ top: 260 });
expect(anchors).toHaveLength(count);
expect(popupService.popups).toHaveLength(0);
subscription.unsubscribe();
} finally {
univer.dispose();
vi.restoreAllMocks();
}
});
it.each([0, 1, 3])('covers a %i-character range through its trailing edge', (length) => {
const context = new Proxy({
font: '',
@@ -467,15 +522,14 @@ describe('DocCanvasPopManagerService', () => {
it('updates object anchored popups after zoom commands and removes popup on dispose', () => {
const { service, popupService, commandService } = createService();
const targetObject = { left: 30, top: 40, width: 50, height: 60 };
const targetObject = new Rect('popup-target', { left: 30, top: 40, width: 50, height: 60 });
const disposable = service.attachPopupToObject(targetObject as never, { componentKey: 'object-menu' }, 'doc-1');
const disposable = service.attachPopupToObject(targetObject, { componentKey: 'object-menu' }, 'doc-1');
const popup = popupService.popups.get('popup-1');
expect(popup?.anchorRect).toEqual({ left: 40, right: 90, top: 60, bottom: 120 });
expect(disposable.canDispose()).toBe(false);
targetObject.left = 60;
targetObject.top = 80;
targetObject.transformByState({ left: 60, top: 80 });
commandService.emit(SetDocZoomRatioOperation.id);
const anchorRect$ = popup?.anchorRect$ as { value?: unknown } | undefined;
expect(anchorRect$?.value).toEqual({ left: 70, right: 120, top: 100, bottom: 160 });
@@ -14,7 +14,8 @@
* limitations under the License.
*/
import { Injector } from '@univerjs/core';
import { CustomRangeType, DocumentDataModel, DocumentFlavor, DrawingTypeEnum, Injector, LocaleService, PositionedObjectLayoutType } from '@univerjs/core';
import { DocumentSkeleton, DocumentViewModel } from '@univerjs/engine-render';
import { describe, expect, it, vi } from 'vitest';
import { DocPrintInterceptorService } from '../doc-print-interceptor.service';
@@ -37,6 +38,55 @@ describe('DocPrintInterceptorService', () => {
expect(service.interceptor.fetchThroughInterceptors(interceptPoints.PRINTING_DOM_COLLECT)(domCollection as never, { unitId: 'doc-1' } as never)).toBe(domCollection);
});
it('prints an inline footnote image only on the page containing its note', () => {
const injector = new Injector([[DocPrintInterceptorService], [LocaleService]]);
const model = new DocumentDataModel({
id: 'note-print',
documentStyle: {
documentFlavor: DocumentFlavor.TRADITIONAL,
pageSize: { width: 300, height: 400 },
marginTop: 20,
marginBottom: 20,
marginLeft: 20,
marginRight: 20,
},
body: {
dataStream: 'A\uFFFC\rB\r\n',
paragraphs: [{ startIndex: 2, paragraphId: 'p1' }, {
startIndex: 4,
paragraphId: 'p2',
paragraphStyle: { pageBreakBefore: 1 },
}],
sectionBreaks: [{ startIndex: 5, sectionId: 's' }],
customRanges: [{ rangeId: 'ref', rangeType: CustomRangeType.FOOTNOTE, startIndex: 1, endIndex: 1, wholeEntity: true, properties: { noteId: 'note' } }],
},
notes: { note: { type: 'footnote' as const, noteId: 'note', body: {
dataStream: '\b\r\n',
paragraphs: [{ startIndex: 1, paragraphId: 'np' }],
customBlocks: [{ startIndex: 0, blockId: 'note-image' }],
}, drawings: { 'note-image': {
drawingId: 'note-image',
unitId: 'note-print',
subUnitId: 'note-print',
drawingType: DrawingTypeEnum.DRAWING_IMAGE,
layoutType: PositionedObjectLayoutType.INLINE,
docTransform: { angle: 0, size: { width: 40, height: 20 }, positionH: { relativeFrom: 0, posOffset: 0 }, positionV: { relativeFrom: 0, posOffset: 0 } },
} }, drawingsOrder: ['note-image'] } },
});
const skeleton = DocumentSkeleton.create(new DocumentViewModel(model), injector.get(LocaleService));
try {
skeleton.calculate();
expect(skeleton.getSkeletonData()?.pages).toHaveLength(2);
const service = injector.get(DocPrintInterceptorService);
expect([...service.getPageDrawingIds(skeleton, 0)!]).toEqual(['note-image']);
expect([...service.getPageDrawingIds(skeleton, 1)!]).toEqual([]);
} finally {
skeleton.dispose();
model.dispose();
injector.dispose();
}
});
it('waits for registered print preparation handlers', async () => {
const service = createService();
const handler = vi.fn(async () => undefined);
@@ -26,6 +26,7 @@ import {
Disposable,
DOC_RANGE_TYPE,
DOCS_NORMAL_EDITOR_UNIT_ID_KEY,
DocumentFlavor,
DrawingTypeEnum,
ErrorService,
generateRandomId,
@@ -77,6 +78,7 @@ import {
DOC_INTERNAL_FRAGMENT_MIME,
embedInternalClipboardFragment,
extractInternalClipboardFragmentFromHtml,
omitClipboardNotes,
parseInternalClipboardFragment,
wrapClipboardHtml,
} from './internal-fragment';
@@ -479,6 +481,10 @@ export class DocClipboardService extends Disposable implements IDocClipboardServ
return false;
}
if (currentDocument.getDocumentStyle().documentFlavor !== DocumentFlavor.TRADITIONAL) {
body = omitClipboardNotes(body);
}
this._clipboardHooks.forEach((hook) => {
if (hook.onBeforePaste) {
body = hook.onBeforePaste(body, {
@@ -14,8 +14,8 @@
* limitations under the License.
*/
import type { IDocumentData } from '@univerjs/core';
import { generateRandomId, Tools } from '@univerjs/core';
import type { IDocumentBody, IDocumentData, IDocumentNote } from '@univerjs/core';
import { BuildTextUtils, createParagraphId, createSectionId, CustomRangeType, generateRandomId, TextX, Tools } from '@univerjs/core';
export const DOC_INTERNAL_FRAGMENT_MIME = 'application/x-doc-fragment+json';
export const DOC_INTERNAL_FRAGMENT_COMMENT_PREFIX = 'univer-doc-fragment:';
@@ -51,10 +51,27 @@ export function parseInternalClipboardFragment(value?: string): Partial<IDocumen
return null;
}
function copyReferencedNotes(doc: IDocumentData): IDocumentData['notes'] {
let copied: IDocumentData['notes'];
for (const range of doc.body?.customRanges ?? []) {
const id = (range.rangeType === CustomRangeType.FOOTNOTE || range.rangeType === CustomRangeType.ENDNOTE) ? range.properties?.noteId : undefined;
if (typeof id === 'string' && doc.notes?.[id]) {
copied ??= {};
copied[id] = Tools.deepClone(doc.notes[id]);
}
}
return copied;
}
export function createInternalClipboardDocData(doc: IDocumentData): Partial<IDocumentData> {
const body = Tools.deepClone(doc.body);
const internalDocData: Partial<IDocumentData> = { body };
const notes = copyReferencedNotes(doc);
if (notes) {
internalDocData.notes = notes;
}
if (body?.tables?.length && doc.tableSource) {
internalDocData.tableSource = {};
body.tables.forEach((tableRange) => {
@@ -138,11 +155,85 @@ export function createInternalClipboardDocDataList(docs: IDocumentData[]): Parti
merged.drawings ??= {};
Object.assign(merged.drawings, part.drawings);
}
if (part.notes) {
merged.notes ??= {};
Object.assign(merged.notes, part.notes);
}
}
return merged.body?.dataStream ? merged : null;
}
/** Removes reference characters through TextX so every following clipboard offset stays valid. */
export function omitClipboardNotes(body: IDocumentBody): IDocumentBody {
const references = body.customRanges?.filter((range) => (range.rangeType === CustomRangeType.FOOTNOTE || range.rangeType === CustomRangeType.ENDNOTE)) ?? [];
if (references.length === 0) {
return body;
}
const textX = new TextX();
let cursor = 0;
for (const reference of references) {
textX.retain(reference.startIndex - cursor).delete(reference.endIndex - reference.startIndex + 1);
cursor = reference.endIndex + 1;
}
return TextX.apply(Tools.deepClone(body), textX.serialize());
}
/** Each pasted reference owns a new rich segment, including its drawing and table identities. */
export function cloneClipboardNotes(body: IDocumentBody, notes: IDocumentData['notes']): Record<string, IDocumentNote> {
const copied: Record<string, IDocumentNote> = {};
for (const range of body.customRanges ?? []) {
if ((range.rangeType !== CustomRangeType.FOOTNOTE && range.rangeType !== CustomRangeType.ENDNOTE)) {
continue;
}
const sourceId = range.properties?.noteId;
const source = typeof sourceId === 'string' ? notes?.[sourceId] : undefined;
if (!source) {
continue;
}
const note = Tools.deepClone(source);
note.noteId = generateRandomId(12);
range.rangeId = generateRandomId(12);
range.properties = { ...range.properties, noteId: note.noteId };
const paragraphIds = new Set<string>();
const sectionIds = new Set<string>();
note.body.paragraphs?.forEach((paragraph) => {
paragraph.paragraphId = createParagraphId(paragraphIds);
});
note.body.sectionBreaks?.forEach((section) => {
section.sectionId = createSectionId(sectionIds);
});
note.body.customRanges = note.body.customRanges?.map((item) => BuildTextUtils.customRange.copyCustomRange(item));
resetNoteResourceIds(note);
copied[note.noteId] = note;
}
return copied;
}
function resetNoteResourceIds(note: IDocumentNote): void {
for (const range of note.body.tables ?? []) {
const previousId = range.tableId;
const table = note.tableSource?.[previousId];
if (table && note.tableSource) {
range.tableId = generateRandomId(12);
table.tableId = range.tableId;
note.tableSource[range.tableId] = table;
delete note.tableSource[previousId];
}
}
for (const block of note.body.customBlocks ?? []) {
const previousId = block.blockId;
const drawing = note.drawings?.[previousId];
if (drawing && note.drawings) {
block.blockId = generateRandomId(12);
drawing.drawingId = block.blockId;
note.drawings[block.blockId] = drawing;
delete note.drawings[previousId];
note.drawingsOrder = note.drawingsOrder?.map((id) => id === previousId ? block.blockId : id);
}
}
}
export function embedInternalClipboardFragment(html: string, fragmentJson: string): string {
return `<!--${DOC_INTERNAL_FRAGMENT_COMMENT_PREFIX}${encodeBase64(fragmentJson)}-->${html}`;
}
@@ -255,6 +255,7 @@ export class DocCanvasPopManagerService extends Disposable {
position,
position$,
disposable,
updatePosition,
};
}
@@ -273,7 +274,9 @@ export class DocCanvasPopManagerService extends Disposable {
return bound;
};
return this._createRectPositionObserver(getBound, currentRender);
const observer = this._createRectPositionObserver(getBound, currentRender);
observer.disposable.add(targetObject.onTransformChange$.subscribeEvent(observer.updatePosition));
return observer;
}
private _createRangePositionObserver(range: ITextRangeParam, currentRender: IRender) {
@@ -125,6 +125,14 @@ function collectPageDrawingIds(page: IDocumentSkeletonPage | IDocumentSkeletonHe
for (const drawingId of page.skeDrawings.keys()) {
drawingIds.add(drawingId);
}
for (const note of page.notes ?? []) {
collectPageDrawingIds(note.page, drawingIds);
}
for (const group of page.skeColumnGroups?.values() ?? []) {
for (const column of group.columns) {
collectPageDrawingIds(column.page, drawingIds);
}
}
// Cell drawings belong to their cell skeleton, not the host page's map.
for (const table of page.skeTables.values()) {
for (const row of table.rows) {
@@ -16,17 +16,19 @@
// @vitest-environment jsdom
import type { IDisposable, IDocumentData } from '@univerjs/core';
import type { IDisposable, IDocumentData, ITextRangeParam } from '@univerjs/core';
import type { Mock } from 'vitest';
import {
DataStreamTreeTokenType,
DOC_RANGE_TYPE,
DOCS_NORMAL_EDITOR_UNIT_ID_KEY,
DocumentDataModel,
DocumentFlavor,
Univer,
UniverInstanceType,
} from '@univerjs/core';
import { DocSelectionManagerService, DocSkeletonManagerService } from '@univerjs/docs';
import { GlyphType, RenderUnit } from '@univerjs/engine-render';
import { DocLayoutExecutorService, DocSelectionManagerService, DocSkeletonManagerService } from '@univerjs/docs';
import { GlyphType, NORMAL_TEXT_SELECTION_PLUGIN_STYLE, RenderUnit } from '@univerjs/engine-render';
import { ILayoutService } from '@univerjs/ui';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import {
@@ -193,6 +195,7 @@ function createService() {
scene,
mainComponent,
unitId: 'unit-1',
unit: new DocumentDataModel({ id: 'unit-1' }),
},
_container: {
style: {},
@@ -306,6 +309,7 @@ class TestRenderEvent<T> {
}
function createRealSelectionRenderService(options: {
documentData?: IDocumentData;
embedInteractionBoundaryService?: Partial<EmbedInteractionBoundaryService>;
embedRuntimeFocusCoordinator?: EmbedRuntimeFocusCoordinator;
mainComponent?: unknown;
@@ -323,7 +327,7 @@ function createRealSelectionRenderService(options: {
if (options.embedRuntimeFocusCoordinator) {
injector.add([EmbedRuntimeFocusCoordinator, { useValue: options.embedRuntimeFocusCoordinator }]);
}
const documentData: IDocumentData = {
const documentData: IDocumentData = options.documentData ?? {
id: 'selection-render-doc',
body: {
dataStream: 'Hello\r\n',
@@ -895,6 +899,60 @@ describe('doc selection render service internals', () => {
});
describe('DocSelectionRenderService', () => {
it.each(['', 'note'])('uses the logical caret in segment "%s" while replacement layout is pending', (segmentId) => {
TestLayoutService.reset();
const univer = new Univer();
const injector = univer.__getInjector();
injector.add([DocSelectionManagerService]);
injector.add([DocLayoutExecutorService]);
injector.add([ILayoutService, { useClass: TestLayoutService as never }]);
const doc = univer.createUnit(UniverInstanceType.UNIVER_DOC, {
id: 'pending-input',
documentStyle: { documentFlavor: DocumentFlavor.TRADITIONAL },
body: { dataStream: 'Body\r\n', paragraphs: [{ paragraphId: 'p', startIndex: 4 }] },
notes: { note: { type: 'footnote' as const, noteId: 'note', body: {
dataStream: 'Note\r\n',
paragraphs: [{ paragraphId: 'np', startIndex: 4 }],
} } },
});
const render = injector.createInstance(RenderUnit, {
engine: {} as never,
scene: { getViewports: () => [], getEngine: () => null, enableObjectsEvent: () => {} } as never,
isMainScene: true,
unit: doc,
});
render.addRenderDependencies([[DocSkeletonManagerService], [DocSelectionRenderService]]);
try {
const service = render.with(DocSelectionRenderService);
render.with(DocSkeletonManagerService).getSkeleton().beginExternalLayout({ reason: 'edit' });
const manager = injector.get(DocSelectionManagerService);
manager.__TEST_ONLY_setCurrentSelection({ unitId: 'pending-input', subUnitId: 'pending-input' });
manager.replaceSelectionInfoWithoutRefresh({
textRanges: [{ startOffset: 3, endOffset: 3, collapsed: true, isActive: true, segmentId }],
rectRanges: [],
segmentId,
segmentPage: -1,
isEditing: true,
style: NORMAL_TEXT_SELECTION_PLUGIN_STYLE,
});
expect(service.replaceDocRanges(manager.getSelectionInfo()!.textRanges)).toBe(false);
expect(service.hasPendingSelection).toBe(true);
const received: Array<ITextRangeParam | null> = [];
const subscription = service.onInput$.subscribe((event) => received.push(event.activeRange ?? null));
try {
const input = document.getElementById('__editor_pending-input')!;
input.textContent = 'X';
input.dispatchEvent(new InputEvent('input', { bubbles: true, inputType: 'insertText', data: 'X' }));
expect(received).toEqual([expect.objectContaining({ startOffset: 3, endOffset: 3, segmentId })]);
} finally {
subscription.unsubscribe();
}
} finally {
render.dispose();
univer.dispose();
}
});
it('cancels an active pointer selection and disposes its cached ranges', () => {
const { scene, service } = createService();
const liveTextRange = createTextRange();
@@ -432,6 +432,11 @@ export class NodePositionConvertToCursor {
if (startPageType === DocumentSkeletonPageType.HEADER || startPageType === DocumentSkeletonPageType.FOOTER) {
return startPage === endPage;
}
if (startPageType === DocumentSkeletonPageType.NOTE) {
const skeleton = this._docSkeleton.getSkeletonData();
return skeleton != null && getPageFromPath(skeleton, startOrigin.path)?.segmentId ===
getPageFromPath(skeleton, endOrigin.path)?.segmentId;
}
return true;
}
@@ -609,6 +614,9 @@ export class NodePositionConvertToCursor {
segmentPage = skeHeaders.get(headerId)?.get(pageWidth);
} else if (pageType === DocumentSkeletonPageType.FOOTER) {
segmentPage = skeFooters.get(footerId)?.get(pageWidth);
} else if (pageType === DocumentSkeletonPageType.NOTE) {
const noteId = getPageFromPath(skeletonData, path)?.segmentId;
segmentPage = page.notes?.find((note) => note.noteId === noteId)?.page;
} else if (pageType === DocumentSkeletonPageType.CELL) {
segmentPage = path[0] === 'pages'
? getPageFromPath(skeletonData, path)
@@ -627,13 +635,18 @@ export class NodePositionConvertToCursor {
startPosition,
endPosition,
sections.length - 1,
pageType === DocumentSkeletonPageType.BODY || pageType === DocumentSkeletonPageType.CELL ? p : 0
pageType === DocumentSkeletonPageType.BODY || pageType === DocumentSkeletonPageType.CELL || pageType === DocumentSkeletonPageType.NOTE ? p : 0
);
this._liquid.translateSave();
const previousHorizontalClip = this._horizontalClip;
this._horizontalClip = null;
switch (pageType) {
case DocumentSkeletonPageType.NOTE: {
const note = page.notes?.find((fragment) => fragment.page === segmentPage);
this._liquid.translate(note?.left ?? page.marginLeft, note?.top ?? page.marginTop);
break;
}
case DocumentSkeletonPageType.HEADER:
this._liquid.translatePagePadding({
...segmentPage,
@@ -674,6 +687,9 @@ export class NodePositionConvertToCursor {
} else if (tablePage?.type === DocumentSkeletonPageType.FOOTER) {
const footerTop = page.pageHeight - tablePage.height - tablePage.marginBottom;
this._liquid.translate(page.marginLeft, footerTop);
} else if (tablePage?.type === DocumentSkeletonPageType.NOTE) {
const note = page.notes?.find((fragment) => fragment.page === tablePage);
this._liquid.translate(note?.left ?? page.marginLeft, note?.top ?? page.marginTop);
} else {
this._liquid.translatePagePadding(page);
}
@@ -283,25 +283,51 @@ export class DocSelectionRenderService extends RxDisposable implements IRenderMo
this._pendingSelection = null;
return false;
}
if (this._currentSegmentId === '' && ranges.length > 0) {
const progress = this._docSkeletonManagerService.getSkeleton().getLayoutProgress();
if (progress?.reason === 'edit' && !progress.anchorReady && !progress.complete && !progress.cancelled) {
const { unitId } = this._context;
const selection = this._docSelectionManagerService.getSelectionInfo({ unitId, subUnitId: unitId });
// Only a refresh of the current logical selection belongs to this
// publication. A new pointer/programmatic selection takes over.
if (
selection != null &&
ranges.length === selection.textRanges.length &&
ranges.every((range, index) => range === selection.textRanges[index])
) {
this._pendingSelection = selection;
this._syncNoteSegment(ranges[0]);
const isBodyOrNote = this._currentSegmentId === '' ||
this._context.unit.getSnapshot().notes?.[this._currentSegmentId] != null;
let currentLogicalSelection: Nullable<IDocSelectionInnerParam> = null;
if (isBodyOrNote && ranges.length > 0) {
const { unitId } = this._context;
const selection = this._docSelectionManagerService.getSelectionInfo({ unitId, subUnitId: unitId });
// Only a refresh of the current logical selection belongs to this
// publication. A new pointer/programmatic selection takes over.
if (selection != null && ranges.length === selection.textRanges.length &&
ranges.every((range, index) => range === selection.textRanges[index])) {
currentLogicalSelection = selection;
const progress = this._docSkeletonManagerService.getSkeleton().getLayoutProgress();
if (progress?.reason === 'edit' && !progress.anchorReady && !progress.complete && !progress.cancelled) {
this._pendingSelection = currentLogicalSelection;
return false;
}
}
}
this._pendingSelection = null;
return this.addDocRanges(ranges, isEditing, options, true);
const replaced = this.addDocRanges(ranges, isEditing, options, true);
if (!replaced) {
this._pendingSelection = currentLogicalSelection;
}
return replaced;
}
private _syncNoteSegment(range: ISuccinctDocRangeParam | undefined): void {
const targetSegment = range?.segmentId;
if (targetSegment == null) {
return;
}
const notes = this._context.unit.getSnapshot().notes;
if (targetSegment !== this._currentSegmentId &&
(notes?.[targetSegment] || notes?.[this._currentSegmentId])) {
this.setSegment(targetSegment);
this.setSegmentPage(range?.segmentPage ?? -1);
}
if (notes?.[targetSegment] && range != null) {
const position = this._docSkeletonManagerService.getSkeleton()
.findNodePositionByCharIndex(range.endOffset, true, targetSegment);
if (position != null && position.page !== this._currentSegmentPage) {
this.setSegmentPage(position.page);
}
}
}
private _getPendingSelection(): Nullable<IDocSelectionInnerParam> {
@@ -697,7 +723,9 @@ export class DocSelectionRenderService extends RxDisposable implements IRenderMo
this._pendingSelection = null;
const { segmentId, segmentPage } = startNode;
if (segmentId && this._currentSegmentId && segmentId !== this._currentSegmentId) {
const notes = this._context.unit.getSnapshot().notes;
if (segmentId !== this._currentSegmentId &&
((segmentId && this._currentSegmentId) || notes?.[segmentId] || notes?.[this._currentSegmentId])) {
this.setSegment(segmentId);
}
@@ -134,7 +134,7 @@ function buildInsertTextActions(
const textActions = JSONX.getInstance().editOp(textX.serialize(), getRichTextEditPath(documentDataModel, segmentId));
return collapsed
? textActions
: appendRemovedDrawingActions(textActions, documentDataModel, originBody, startOffset, endOffset);
: appendRemovedDrawingActions(textActions, documentDataModel, originBody, startOffset, endOffset, segmentId);
}
function appendRemovedDrawingActions(
@@ -142,13 +142,14 @@ function appendRemovedDrawingActions(
documentDataModel: DocumentDataModel,
body: IDocumentBody,
startOffset: number,
endOffset: number
endOffset: number,
segmentId?: string
): IRichTextEditingMutationParams['actions'] {
const drawingActions = BuildTextUtils.drawing.remove(documentDataModel.getSnapshot(), [{
startOffset,
endOffset,
collapsed: false,
}], body);
}], body, segmentId);
const rawActions = [textActions, ...drawingActions];
return rawActions.reduce((accumulator, action) => JSONX.compose(accumulator, action));
@@ -221,7 +222,8 @@ export const DeleteTextCommand: ICommand<IDeleteTextCommandParams> = {
docDataModel,
body,
start,
end + 1
end + 1,
segmentId
);
const result = commandService.syncExecuteCommand<
@@ -14,10 +14,13 @@
* limitations under the License.
*/
import { ICommandService, JSONX } from '@univerjs/core';
import type { IRichTextEditingMutationParams } from '../core-editing.mutation';
import { CustomRangeType, DocumentFlavor, ICommandService, JSONX, TextX } from '@univerjs/core';
import { NORMAL_TEXT_SELECTION_PLUGIN_STYLE } from '@univerjs/engine-render';
import { describe, expect, it, vi } from 'vitest';
import { createTestBed } from '../../../facade/__tests__/create-test-bed';
import { createDocumentData, createTestBed } from '../../../facade/__tests__/create-test-bed';
import { DocSelectionManagerService } from '../../../services/doc-selection-manager.service';
import { InsertTextCommand } from '../../commands/core-editing.command';
import { RichTextEditingMutation, transformDocumentTextRanges } from '../core-editing.mutation';
describe('transformDocumentTextRanges', () => {
@@ -76,6 +79,132 @@ describe('transformDocumentTextRanges', () => {
});
describe('RichTextEditingMutation selection scheduling', () => {
it('rejects a remote footnote whose segment identity belongs to a header', () => {
const snapshot = createDocumentData('note-collision', {
dataStream: 'Body\r\n',
paragraphs: [{ paragraphId: 'p', startIndex: 4 }],
sectionBreaks: [{ sectionId: 's', startIndex: 5 }],
});
snapshot.headers = { shared: { headerId: 'shared', body: {
dataStream: 'Header\r\n',
paragraphs: [{ paragraphId: 'hp', startIndex: 6 }],
sectionBreaks: [{ sectionId: 'hs', startIndex: 7 }],
} } };
const bed = createTestBed(snapshot);
try {
const before = structuredClone(bed.doc.getSnapshot());
expect(() => bed.get(ICommandService).syncExecuteCommand(RichTextEditingMutation.id, {
unitId: snapshot.id,
segmentId: '',
textRanges: null,
actions: JSONX.getInstance().insertOp(['notes', 'shared'], { type: 'footnote' as const, noteId: 'shared', body: { dataStream: '\r\n', paragraphs: [{ paragraphId: 'np', startIndex: 0 }], sectionBreaks: [{ sectionId: 'ns', startIndex: 1 }] } }),
}, { fromCollab: true })).toThrow('invalid-note-id');
expect(bed.doc.getSnapshot()).toEqual(before);
expect(bed.doc.getSelfOrHeaderFooterModel('shared')?.getBody()?.dataStream).toBe('Header\r\n');
} finally {
bed.univer.dispose();
}
});
it('keeps consecutive text, spaces and paragraphs in the active footnote', async () => {
const snapshot = createDocumentData('note-input', {
dataStream: 'A\uFFFCB\r\n',
paragraphs: [{ paragraphId: 'p', startIndex: 3 }],
sectionBreaks: [{ sectionId: 's', startIndex: 4 }],
customRanges: [{ rangeId: 'ref', rangeType: CustomRangeType.FOOTNOTE, startIndex: 1, endIndex: 1, wholeEntity: true, properties: { noteId: 'note' } }],
});
snapshot.documentStyle = { ...snapshot.documentStyle, documentFlavor: DocumentFlavor.TRADITIONAL };
snapshot.notes = { note: { type: 'footnote' as const, noteId: 'note', body: {
dataStream: '\r\n',
paragraphs: [{ paragraphId: 'np', startIndex: 0 }],
sectionBreaks: [{ sectionId: 'ns', startIndex: 1 }],
} } };
const bed = createTestBed(snapshot);
const selectionManager = bed.get(DocSelectionManagerService);
const target = { unitId: 'note-input', subUnitId: 'note-input' };
selectionManager.__TEST_ONLY_setCurrentSelection(target);
selectionManager.replaceSelectionInfoWithoutRefresh({
textRanges: [{ startOffset: 0, endOffset: 0, collapsed: true, isActive: true, segmentId: 'note' }],
rectRanges: [],
segmentId: 'note',
segmentPage: 0,
isEditing: true,
style: NORMAL_TEXT_SELECTION_PLUGIN_STYLE,
}, target);
try {
for (const text of ['First', ' ', 'line', '\r', 'Next', ' ', 'line']) {
const range = selectionManager.getActiveTextRange()!;
expect(range.segmentId).toBe('note');
bed.get(ICommandService).syncExecuteCommand(InsertTextCommand.id, {
unitId: target.unitId,
range,
body: text === '\r'
? { dataStream: text, paragraphs: [{ paragraphId: 'next-p', startIndex: 0 }] }
: { dataStream: text },
});
await Promise.resolve();
expect(selectionManager.getSelectionInfo()?.segmentId).toBe('note');
}
expect(bed.doc.getSnapshot().notes?.note.body.dataStream).toBe('First line\rNext line\r\n');
expect(bed.doc.getBody()?.dataStream).toBe('A\uFFFCB\r\n');
} finally {
bed.univer.dispose();
}
});
it.each([1, 2])('publishes deletion at offset %i and its collaborative undo atomically', (offset) => {
const snapshot = createDocumentData('note-delete', {
dataStream: 'A\uFFFCB\r\n',
customDecorations: [],
paragraphs: [{ paragraphId: 'p', startIndex: 3 }],
sectionBreaks: [{ sectionId: 's', startIndex: 4 }],
customRanges: [{ rangeId: 'ref', rangeType: CustomRangeType.FOOTNOTE, startIndex: 1, endIndex: 1, wholeEntity: true, properties: { noteId: 'note' } }],
});
snapshot.documentStyle = { ...snapshot.documentStyle, documentFlavor: DocumentFlavor.TRADITIONAL };
snapshot.notes = { note: { type: 'footnote' as const, noteId: 'note', body: {
dataStream: 'Explanation\r\n',
paragraphs: [{ paragraphId: 'np', startIndex: 11 }],
sectionBreaks: [{ sectionId: 'ns', startIndex: 12 }],
} } };
const bed = createTestBed(structuredClone(snapshot));
const peer = createTestBed(structuredClone(snapshot));
const params: IRichTextEditingMutationParams = {
unitId: 'note-delete',
segmentId: '',
textRanges: null,
actions: JSONX.getInstance().editOp(new TextX().retain(offset).delete(1).serialize(), ['body']),
};
try {
const before = structuredClone(bed.doc.getSnapshot());
const expectedText = offset === 1 ? 'AB\r\n' : 'A\uFFFC\r\n';
const noteExists = offset !== 1;
const observed: Array<{ text?: string; noteExists: boolean }> = [];
const subscription = bed.doc.change$.subscribe(() => observed.push({
text: bed.doc.getBody()?.dataStream,
noteExists: bed.doc.getSnapshot().notes?.note != null,
}));
observed.length = 0;
const apply = vi.spyOn(JSONX, 'apply');
const undo = bed.get(ICommandService).syncExecuteCommand<IRichTextEditingMutationParams, IRichTextEditingMutationParams>(RichTextEditingMutation.id, params);
expect(apply).toHaveBeenCalledTimes(offset === 1 ? 2 : 1);
apply.mockRestore();
expect(bed.doc.getBody()?.dataStream).toBe(expectedText);
expect(bed.doc.getSnapshot().notes?.note != null).toBe(noteExists);
expect(observed).toEqual([{ text: expectedText, noteExists }]);
peer.get(ICommandService).syncExecuteCommand(RichTextEditingMutation.id, params, { fromCollab: true });
expect(peer.doc.getSnapshot()).toEqual(bed.doc.getSnapshot());
bed.get(ICommandService).syncExecuteCommand(RichTextEditingMutation.id, undo);
peer.get(ICommandService).syncExecuteCommand(RichTextEditingMutation.id, undo, { fromCollab: true });
expect(bed.doc.getSnapshot()).toEqual(before);
expect(peer.doc.getSnapshot()).toEqual(before);
expect(observed).toEqual([{ text: expectedText, noteExists }, { text: 'A\uFFFCB\r\n', noteExists: true }]);
subscription.unsubscribe();
} finally {
bed.univer.dispose();
peer.univer.dispose();
}
});
it('does not refresh selection for metadata changes without ranges', async () => {
const testBed = createTestBed();
const selectionManager = testBed.get(DocSelectionManagerService);
@@ -16,21 +16,27 @@
import type {
DocumentDataModel,
ICustomRange,
IDocumentData,
IExecutionOptions,
IMutation,
IMutationCommonParams,
JSONXActions,
Nullable,
TextXAction,
TPriority,
} from '@univerjs/core';
import type { DocumentViewModel, ITextRangeWithStyle } from '@univerjs/engine-render';
import type { IDocStateChangeInfo } from '../../services/doc-state-emit.service';
import {
CommandType,
CustomRangeType,
IUniverInstanceService,
JSON1,
JSONX,
TextX,
TextXActionType,
Tools,
UniverInstanceType,
} from '@univerjs/core';
import { IRenderManagerService } from '@univerjs/engine-render';
@@ -124,17 +130,78 @@ export function transformDocumentTextRanges(
});
}
function canRemoveFootnoteReference(actions: JSONXActions, references: ICustomRange[]): boolean {
let canRemove = false;
const cursor = JSON1.type.readCursor(actions);
cursor.traverse(null, (component) => {
const path = cursor.getPath();
if (path.length === 0) {
canRemove = true;
}
if (canRemove || path[0] !== 'body') {
return;
}
if (path.length === 1 && component.et === TextX.id && Array.isArray(component.e)) {
let offset = 0;
for (const action of component.e as TextXAction[]) {
if (action.t === TextXActionType.DELETE && references.some((reference) =>
offset <= reference.endIndex && offset + action.len > reference.startIndex
)) {
canRemove = true;
return;
}
if (action.t === TextXActionType.RETAIN && action.body?.customRanges != null) {
canRemove = true;
return;
}
if (action.t !== TextXActionType.INSERT) {
offset += action.len;
}
}
} else if (path.length === 1 || path[1] === 'customRanges' || path[1] === 'dataStream') {
canRemove = true;
}
});
return canRemove;
}
function includeFootnoteCleanup(before: IDocumentData, actions: JSONXActions): JSONXActions {
if (!before.notes) {
return actions;
}
const references = before.body?.customRanges?.filter((range) => (range.rangeType === CustomRangeType.FOOTNOTE || range.rangeType === CustomRangeType.ENDNOTE)) ?? [];
if (references.length === 0 || !canRemoveFootnoteReference(actions, references)) {
return actions;
}
// TextX edits mutate their body. Isolate the cleanup preview, and only run it
// for edits that can remove a reference; ordinary typing keeps the fast path.
const after = JSONX.apply(Tools.deepClone(before), actions) as unknown as IDocumentData;
const remaining = new Set(after.body?.customRanges?.filter((range) => (range.rangeType === CustomRangeType.FOOTNOTE || range.rangeType === CustomRangeType.ENDNOTE))
.map((range) => range.properties?.noteId));
let cleanup: JSONXActions = null;
const previousIds = new Set(references.map((reference) => reference.properties?.noteId));
for (const id of previousIds) {
if (typeof id === 'string' && !remaining.has(id) && after.notes?.[id]) {
cleanup = JSONX.compose(cleanup, JSONX.getInstance().removeOp(['notes', id], after.notes[id]));
}
}
return JSONX.isNoop(cleanup) ? actions : JSONX.compose(actions, cleanup);
}
function applyValidatedDocumentActions(
documentDataModel: DocumentDataModel,
segmentId: string,
actions: JSONXActions
): { undoActions: JSONXActions; preservesStructure: boolean } {
const undoActions = JSONX.invertWithDoc(actions, documentDataModel.getSnapshot());
documentDataModel.apply(actions);
): { actions: JSONXActions; undoActions: JSONXActions; preservesStructure: boolean } {
const before = documentDataModel.getSnapshot();
const appliedActions = includeFootnoteCleanup(before, actions);
const undoActions = JSONX.invertWithDoc(appliedActions, before);
documentDataModel.apply(appliedActions);
try {
return {
actions: appliedActions,
undoActions,
preservesStructure: validateDocStructureMutation(documentDataModel, segmentId, actions, undoActions),
preservesStructure: validateDocStructureMutation(documentDataModel, segmentId, appliedActions, undoActions),
};
} catch (error) {
documentDataModel.apply(undoActions);
@@ -172,17 +239,18 @@ function scheduleDocumentSelectionUpdate(
}
const selectionTarget = { unitId, subUnitId: unitId };
const currentSelection = selectionManager.getSelectionInfo(selectionTarget);
const logicalTextRanges = textRanges.map((textRange, index) => ({
...textRange,
segmentId: textRange.segmentId ?? params.segmentId ?? '',
collapsed: textRange.startOffset === textRange.endOffset,
isActive: index === textRanges.length - 1,
}));
if (currentSelection != null) {
const logicalTextRanges = textRanges.map((textRange, index) => ({
...textRange,
collapsed: textRange.startOffset === textRange.endOffset,
isActive: index === textRanges.length - 1,
}));
// Advance logical intent with the mutation. Only its visual refresh is
// deferred; a later input or pointer selection must supersede this one.
selectionManager.replaceSelectionInfoWithoutRefresh({
...currentSelection,
segmentId: logicalTextRanges[logicalTextRanges.length - 1].segmentId,
textRanges: logicalTextRanges,
rectRanges: [],
isEditing,
@@ -195,7 +263,7 @@ function scheduleDocumentSelectionUpdate(
return;
}
if (updatedSelection == null) {
selectionManager.replaceDocRanges(textRanges, selectionTarget, isEditing, params.options);
selectionManager.replaceDocRanges(logicalTextRanges, selectionTarget, isEditing, params.options);
} else {
selectionManager.refreshSelection(selectionTarget, isEditing);
}
@@ -255,13 +323,15 @@ export const RichTextEditingMutation: IMutation<IRichTextEditingMutationParams,
};
}
const { undoActions, preservesStructure } = applyValidatedDocumentActions(
const { actions: appliedActions, undoActions, preservesStructure } = applyValidatedDocumentActions(
documentDataModel,
segmentId,
actions
);
resetDocumentViewModel(documentViewModel, documentDataModel, segmentId, actions, preservesStructure);
// Publish reference deletion and note cleanup in the same deterministic mutation.
params.actions = appliedActions;
resetDocumentViewModel(documentViewModel, documentDataModel, segmentId, appliedActions, preservesStructure);
scheduleDocumentSelectionUpdate(docSelectionManagerService, params, isSync);
// Step 4: Emit state change event.
@@ -273,7 +343,7 @@ export const RichTextEditingMutation: IMutation<IRichTextEditingMutationParams,
noHistory,
debounce,
redoState: {
actions,
actions: appliedActions,
textRanges,
options: params.options,
isEditing,
@@ -16,6 +16,7 @@
import type { DocumentDataModel, IDocumentBody, JSONXActions, JSONXPath } from '@univerjs/core';
import {
CustomRangeType,
DataStreamTreeTokenType,
getRichTextEditPath,
@@ -25,6 +26,7 @@ import {
TextX,
TextXActionType,
validateDocBodyStructure,
validateDocumentStructure,
} from '@univerjs/core';
const STRUCTURAL_BODY_FIELDS = [
@@ -155,7 +157,7 @@ function isStructurePreservingJSONXEdit(actions: JSONXActions, expectedPath: JSO
return hasComponent && isStructurePreserving;
}
function getSegmentType(documentDataModel: DocumentDataModel, segmentId: string): 'body' | 'header' | 'footer' {
function getSegmentType(documentDataModel: DocumentDataModel, segmentId: string): 'body' | 'header' | 'footer' | 'note' {
if (!segmentId) {
return 'body';
}
@@ -169,6 +171,10 @@ function getSegmentType(documentDataModel: DocumentDataModel, segmentId: string)
return 'footer';
}
if (documentDataModel.getSnapshot().notes?.[segmentId]) {
return 'note';
}
return 'body';
}
@@ -180,7 +186,10 @@ function assertValidDocBodyStructure(documentDataModel: DocumentDataModel, segme
}
const segmentType = getSegmentType(documentDataModel, segmentId);
const issues = validateDocBodyStructure(body, { segmentType, segmentId: segmentId || undefined });
const note = documentDataModel.getSnapshot().notes?.[segmentId];
const issues = note
? validateDocumentStructure({ notes: { [segmentId]: note } })
: validateDocBodyStructure(body, { segmentType, segmentId: segmentId || undefined });
if (!issues.length) {
return;
}
@@ -201,8 +210,29 @@ export function validateDocStructureMutation(
isStructurePreservingJSONXEdit(actions, editPath) &&
isStructurePreservingJSONXEdit(undoActions, editPath);
if (!preservesStructure) {
let changesFootnoteStructure = false;
const cursor = JSON1.type.readCursor(actions);
cursor.traverse(null, (component) => {
const path = cursor.getPath();
if ((path[0] === 'notes' && (path.length <= 2 || path[2] !== 'body')) || path.includes('customRanges')) {
changesFootnoteStructure = true;
}
if (component.et === TextX.id && Array.isArray(component.e)) {
for (const action of component.e) {
const ranges: unknown = isRecord(action) && isRecord(action.body) ? action.body.customRanges : undefined;
if (Array.isArray(ranges) && ranges.some((range) => isRecord(range) && (range.rangeType === CustomRangeType.FOOTNOTE || range.rangeType === CustomRangeType.ENDNOTE))) {
changesFootnoteStructure = true;
}
}
}
});
if (changesFootnoteStructure) {
const issues = validateDocumentStructure(documentDataModel.getSnapshot());
if (issues.length > 0) {
throw new Error(`[DocStructure] ${issues.map((issue) => `${issue.code}@${issue.index ?? issue.segmentId ?? ''}`).join(', ')}`);
}
} else if (!preservesStructure) {
assertValidDocBodyStructure(documentDataModel, segmentId);
}
return preservesStructure;
return preservesStructure && !changesFootnoteStructure;
}
@@ -0,0 +1,47 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { JSONX } from '@univerjs/core';
import { describe, expect, it } from 'vitest';
import { collectDocumentFontFamilies } from '../document-font-metrics';
describe('document font metrics', () => {
it('collects inherited, header and footnote families and font changes without inspecting text content', () => {
const families = new Set<string>();
collectDocumentFontFamilies({
documentStyle: { textStyle: { ff: 'Body Font' } },
headers: { first: { body: { textRuns: [{ ts: { ff: 'Header Font' } }] } } },
notes: { first: { body: { textRuns: [{ ts: { ff: 'Note Font' } }] } } },
body: { dataStream: '{"ff":"Not a font"}', textRuns: [{ ts: { ff: 'Body Font' } }] },
}, families);
collectDocumentFontFamilies(JSONX.getInstance().replaceOp(
['documentStyle', 'textStyle', 'ff'],
'Body Font',
'Replacement Font'
), families);
collectDocumentFontFamilies([{ actions: ['body', {
et: 'textX',
e: [{ t: 'retain', len: 1, body: { textRuns: [{ ts: { ff: 'Pasted Font' } }] } }],
}] }], families);
expect([...families].sort()).toEqual([
'Body Font',
'Header Font',
'Note Font',
'Pasted Font',
'Replacement Font',
]);
});
});
@@ -18,11 +18,13 @@ import {
createDocumentModelWithStyle,
CustomRangeType,
DocumentFlavor,
JSONX,
LocaleType,
ObjectRelativeFromH,
ObjectRelativeFromV,
PositionedObjectLayoutType,
} from '@univerjs/core';
import { getFontStyleString, invalidateDocumentFontMetrics } from '@univerjs/engine-render';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { DocLayoutSessionStatus } from '../../services/doc-layout-executor.service';
import { DocsLayoutWorkerPerformanceTracker } from '../performance-tracker';
@@ -50,9 +52,74 @@ function stubOffscreenCanvas(): void {
describe('DocsLayoutWorkerRuntime', () => {
afterEach(() => {
invalidateDocumentFontMetrics(() => true);
vi.unstubAllGlobals();
});
it('uses browser normal font spacing for initial layout and a newly introduced font during editing', async () => {
stubOffscreenCanvas();
vi.stubGlobal('document', undefined);
const source = createDocumentModelWithStyle('Normal spacing', { ff: 'Initial Font', fs: 12 });
source.updateDocumentStyle({ documentFlavor: DocumentFlavor.TRADITIONAL });
source.updateDocumentDataPageSize(300, 300);
source.updateDocumentDataMargin({ t: 0, r: 0, b: 0, l: 0 });
const snapshot = structuredClone(source.getSnapshot());
const runtime = new DocsLayoutWorkerRuntime();
const identity = { unitId: source.getUnitId(), mountId: 'font-mount', mountEpoch: 1, viewportEpoch: 1 };
const fontKey = (ff: string) => getFontStyleString({ ff, fs: 1000 }).fontString.trim();
invalidateDocumentFontMetrics(() => true);
await runtime.createSession({
unitId: identity.unitId,
sessionEpoch: 1,
snapshot,
modelRevision: 0,
locale: LocaleType.EN_US,
direction: 'ltr',
normalFontLineHeights: { [fontKey('Initial Font')]: 1.5 },
});
try {
for (const revision of [0, 1]) {
const start = await runtime.startLayout({
...identity,
metricsRevision: revision + 1,
baseRevision: 0,
modelRevision: revision,
mutations: revision === 0
? []
: [{
baseRevision: 0,
modelRevision: 1,
actions: JSONX.getInstance().replaceOp(
['body', 'textRuns', 0, 'ts', 'ff'],
'Initial Font',
'Edited Font'
),
}],
normalFontLineHeights: revision === 0 ? undefined : { [fontKey('Edited Font')]: 1.75 },
reason: revision === 0 ? 'initial' : 'edit',
budgetMs: 8,
});
if (start.status !== DocLayoutSessionStatus.ACCEPTED) {
throw new Error('Expected layout to accept the font measurement update.');
}
let step = start.step;
await expect.poll(async () => {
if (!step.progress.complete) {
step = await runtime.stepLayout({ ...identity, generation: step.progress.generation, budgetMs: 8 });
}
return step.progress.complete;
}).toBe(true);
const result = await runtime.getLayoutPage({ ...identity, pageIndex: 0 });
const line = result.page?.page.sections[0].columns[0].lines[0];
expect(line?.lineHeight).toBeCloseTo(revision === 0 ? 18 : 21);
expect(line?.divides.flatMap((divide) => divide.glyphGroup).map((glyph) => glyph.raw).join(''))
.toBe(snapshot.body!.dataStream);
}
} finally {
await runtime.disposeSession({ unitId: identity.unitId, sessionEpoch: 1 });
}
});
it('applies custom range presentations to the matching body, header, and footer segments', async () => {
stubOffscreenCanvas();
const sourceModel = createDocumentModelWithStyle('Body\r', {});
@@ -213,7 +280,7 @@ describe('DocsLayoutWorkerRuntime', () => {
});
await expect(runtime.getCapabilities()).resolves.toMatchObject({
protocolVersion: 4,
protocolVersion: 5,
executor: 'worker',
offscreenCanvas: true,
structuredClone: true,
@@ -0,0 +1,55 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { BooleanNumber } from '@univerjs/core';
import { FontCache, getFontStyleString } from '@univerjs/engine-render';
/** Collect once from the snapshot, then only from mutation payloads (including JSON1 font replacements). */
export function collectDocumentFontFamilies(value: unknown, families: Set<string>): void {
if (value == null || typeof value !== 'object') {
return;
}
if (Array.isArray(value)) {
for (let index = 0; index < value.length; index++) {
const item = value[index];
if (value[index - 1] === 'ff' && item != null && typeof item === 'object' && typeof item.i === 'string') {
families.add(item.i);
}
collectDocumentFontFamilies(item, families);
}
return;
}
for (const [key, item] of Object.entries(value)) {
if (key === 'ff' && typeof item === 'string') {
families.add(item);
} else {
collectDocumentFontFamilies(item, families);
}
}
}
export function measureDocumentFontFamilies(families: Set<string>): Record<string, number> {
FontCache.getNormalLineHeight(getFontStyleString());
// Bold/italic may be inherited separately from the family in paragraph or run styles.
for (const ff of ['', ...families]) {
for (const bl of [BooleanNumber.FALSE, BooleanNumber.TRUE]) {
for (const it of [BooleanNumber.FALSE, BooleanNumber.TRUE]) {
FontCache.getNormalLineHeight(getFontStyleString({ ff, bl, it }));
}
}
}
return FontCache.getNormalLineHeightCache();
}
+18 -3
View File
@@ -26,6 +26,7 @@ import pkg from '../../package.json';
import { UniverDocsPlugin } from '../plugin';
import { DocLayoutExecutorService, DocLayoutExecutorType } from '../services/doc-layout-executor.service';
import { DEFAULT_DOCS_LAYOUT_WORKER_REQUEST_TIMEOUT_MS, defaultPluginDocsLayoutWorkerConfig, DOCS_LAYOUT_WORKER_PLUGIN_CONFIG_KEY } from './config/config';
import { collectDocumentFontFamilies, measureDocumentFontFamilies } from './document-font-metrics';
import { DocsLayoutWorkerPerformanceTracker } from './performance-tracker';
import { DOCS_LAYOUT_WORKER_CHANNEL, DOCS_LAYOUT_WORKER_PROTOCOL_VERSION } from './protocol';
import { startDocsLayoutWorker } from './worker';
@@ -65,6 +66,7 @@ export class DocsLayoutWorkerClientService extends Disposable implements IDocLay
private _initialization: Promise<void>;
private _initialized = false;
private readonly _performanceTracker = new DocsLayoutWorkerPerformanceTracker();
private readonly _fontFamilies = new Map<string, Set<string>>();
constructor(
private readonly _workerFactory: () => Worker,
@@ -88,18 +90,29 @@ export class DocsLayoutWorkerClientService extends Disposable implements IDocLay
}
createSession(request: IDocLayoutCreateSessionRequest): Promise<void> {
const families = new Set<string>();
collectDocumentFontFamilies(request.snapshot, families);
this._fontFamilies.set(request.unitId, families);
const measuredRequest = { ...request, normalFontLineHeights: measureDocumentFontFamilies(families) };
if (this._initialized) {
return this._withTimeout(this._getRuntime().createSession(request), 'create session');
return this._withTimeout(this._getRuntime().createSession(measuredRequest), 'create session');
}
const capturedRequest = Tools.deepClone(request);
const capturedRequest = Tools.deepClone(measuredRequest);
return this._initialization.then(() =>
this._withTimeout(this._getRuntime().createSession(capturedRequest), 'create session'));
}
async startLayout(request: IDocLayoutStartRequest): Promise<IDocLayoutStartResult> {
await this._initialization;
return this._withTimeout(this._getRuntime().startLayout(request), 'start layout');
const families = this._fontFamilies.get(request.unitId);
if (families != null) {
collectDocumentFontFamilies(request.mutations, families);
}
const measuredRequest = families == null
? request
: { ...request, normalFontLineHeights: measureDocumentFontFamilies(families) };
return this._withTimeout(this._getRuntime().startLayout(measuredRequest), 'start layout');
}
async stepLayout(request: IDocLayoutStepRequest): Promise<IDocLayoutStepResult> {
@@ -156,6 +169,7 @@ export class DocsLayoutWorkerClientService extends Disposable implements IDocLay
await this._initialization;
await this._withTimeout(this._getRuntime().disposeSession(request), 'dispose session');
this._performanceTracker.reset(request.unitId);
this._fontFamilies.delete(request.unitId);
}
override dispose(): void {
@@ -178,6 +192,7 @@ export class DocsLayoutWorkerClientService extends Disposable implements IDocLay
}
private _disposeRuntime(): void {
this._fontFamilies.clear();
this._initialized = false;
this._channelService?.dispose();
this._channelService = null;
+1 -1
View File
@@ -17,7 +17,7 @@
import type { DocLayoutExecutorType, IDocLayoutExecutor } from '../services/doc-layout-executor.service';
export const DOCS_LAYOUT_WORKER_CHANNEL = 'univer.docs-layout-worker';
export const DOCS_LAYOUT_WORKER_PROTOCOL_VERSION = 4;
export const DOCS_LAYOUT_WORKER_PROTOCOL_VERSION = 5;
export interface IDocsLayoutWorkerFontProbe {
content: string;
@@ -142,6 +142,7 @@ export class DocsLayoutWorkerRuntime implements IDocsLayoutWorkerRuntime {
}
this._disposeSession(request.unitId);
FontCache.setNormalLineHeightCache(request.normalFontLineHeights ?? {});
const dataModel = new DocumentDataModel(request.snapshot);
const localeService = new LocaleService();
if (request.localeData != null) {
@@ -179,6 +180,8 @@ export class DocsLayoutWorkerRuntime implements IDocsLayoutWorkerRuntime {
};
}
FontCache.setNormalLineHeightCache(request.normalFontLineHeights ?? {});
this._updateCustomRangePresentations(session, request);
this._applyCustomRangePresentations(session.dataModel, Array.from(session.customRangePresentations.values()));
@@ -90,6 +90,7 @@ export interface IDocLayoutPerformanceMetrics {
}
export interface IDocLayoutCreateSessionRequest {
normalFontLineHeights?: Record<string, number>;
unitId: string;
sessionEpoch: number;
snapshot: IDocumentData;
@@ -118,6 +119,7 @@ export interface IDocLayoutMountIdentity {
}
export interface IDocLayoutStartRequest extends IDocLayoutMountIdentity {
normalFontLineHeights?: Record<string, number>;
metricsRevision: number;
baseRevision: number;
modelRevision: number;
@@ -14,12 +14,55 @@
* limitations under the License.
*/
import { DataStreamTreeTokenType, DocumentDataModel } from '@univerjs/core';
import { CustomRangeType, DataStreamTreeTokenType, DocumentDataModel, DrawingTypeEnum, ObjectRelativeFromH, ObjectRelativeFromV, PositionedObjectLayoutType } from '@univerjs/core';
import { describe, expect, it } from 'vitest';
import { getDocumentParagraphPermissionObjectId } from '../document-permission';
import { getDocumentEntityPermissionObjectId, getDocumentParagraphPermissionObjectId, getDocumentSectionPermissionObjectId } from '../document-permission';
import { getDocumentEditTargetObjectIds } from '../document-permission-resolver';
describe('document permission resolver', () => {
it('uses note-owned drawing permissions while retaining the body reference restrictions', () => {
const document = new DocumentDataModel({
id: 'note-permission',
body: {
dataStream: 'A\uFFFC\r\n',
paragraphs: [{ startIndex: 2, paragraphId: 'body-paragraph' }],
sectionBreaks: [{ startIndex: 3, sectionId: 'body-section' }],
customRanges: [{ startIndex: 1, endIndex: 1, rangeId: 'note-ref', rangeType: CustomRangeType.FOOTNOTE, properties: { noteId: 'note' } }],
},
notes: {
note: { type: 'footnote' as const, noteId: 'note', body: {
dataStream: '\b\r\n',
customBlocks: [{ startIndex: 0, blockId: 'image' }],
paragraphs: [{ startIndex: 1, paragraphId: 'note-paragraph' }],
sectionBreaks: [{ startIndex: 2, sectionId: 'note-section' }],
}, drawings: {
image: {
unitId: 'note-permission',
subUnitId: 'note-permission',
drawingId: 'image',
drawingType: DrawingTypeEnum.DRAWING_IMAGE,
layoutType: PositionedObjectLayoutType.INLINE,
docTransform: {
size: { width: 100, height: 40 },
angle: 0,
positionH: { relativeFrom: ObjectRelativeFromH.PAGE, posOffset: 0 },
positionV: { relativeFrom: ObjectRelativeFromV.PAGE, posOffset: 0 },
},
},
} },
},
});
try {
const targets = getDocumentEditTargetObjectIds(document, 'note', { startOffset: 0, endOffset: 1 });
expect(targets).toContain(getDocumentEntityPermissionObjectId('note', 'drawing', 'image'));
expect(targets).not.toContain(getDocumentEntityPermissionObjectId('note', 'custom-block', 'image'));
expect(targets).toContain(getDocumentSectionPermissionObjectId('', 'body-section'));
expect(targets).toContain(getDocumentParagraphPermissionObjectId('', 'body-paragraph'));
} finally {
document.dispose();
}
});
it('indexes paragraph content starts once per document revision', () => {
const paragraphCount = 1_000;
const paragraphs = Array.from({ length: paragraphCount }, (_, index) => ({
@@ -23,8 +23,10 @@ import type {
TextXAction,
} from '@univerjs/core';
import {
CustomRangeType,
getParagraphContentStartOffset,
getParagraphContentStartOffsets,
JSON1,
resolveSectionHeaderFooterReference,
TextX,
TextXActionType,
@@ -60,6 +62,7 @@ interface IDocumentPermissionSegmentIndex {
interface IDocumentPermissionResolverIndex {
drawingSegmentIds: Map<string, string>;
footnoteReferenceRanges: Map<string, IDocumentPermissionRange>;
mutationRevision: number;
segments: Map<string, IDocumentPermissionSegmentIndex>;
topLevelSections: ISectionBreak[];
@@ -93,9 +96,20 @@ function getDocumentPermissionResolverIndex(documentDataModel: DocumentDataModel
addDrawingSegments(snapshot.body, '');
Object.entries(snapshot.headers ?? {}).forEach(([segmentId, header]) => addDrawingSegments(header.body, segmentId));
Object.entries(snapshot.footers ?? {}).forEach(([segmentId, footer]) => addDrawingSegments(footer.body, segmentId));
Object.entries(snapshot.notes ?? {}).forEach(([segmentId, footnote]) => addDrawingSegments(footnote.body, segmentId));
const footnoteReferenceRanges = new Map<string, IDocumentPermissionRange>();
for (const reference of snapshot.body?.customRanges ?? []) {
if ((reference.rangeType === CustomRangeType.FOOTNOTE || reference.rangeType === CustomRangeType.ENDNOTE) && typeof reference.properties?.noteId === 'string') {
footnoteReferenceRanges.set(reference.properties.noteId, {
startOffset: reference.startIndex,
endOffset: reference.endIndex + 1,
});
}
}
const index: IDocumentPermissionResolverIndex = {
drawingSegmentIds,
footnoteReferenceRanges,
mutationRevision,
segments: new Map(),
topLevelSections,
@@ -115,7 +129,9 @@ function getDocumentPermissionSegmentIndex(
}
const body = documentDataModel.getSelfOrHeaderFooterModel(segmentId)?.getBody() ?? null;
const drawings = documentDataModel.getSnapshot().drawings ?? {};
const snapshot = documentDataModel.getSnapshot();
const note = snapshot.notes?.[segmentId];
const drawings = (note ? note.drawings : snapshot.drawings) ?? {};
const entities: IDocumentPermissionEntityRange[] = body == null
? []
: [
@@ -179,12 +195,39 @@ export function getDocumentEditTargetObjectIdsFromActions(
segmentId: string,
actions: JSONXActions
): string[] {
const textActions = getTextXActions(actions);
const result = new Set<string>();
if (textActions) {
if (actions == null) {
return [];
}
const cursor = JSON1.type.readCursor(actions);
cursor.traverse(null, () => {
const path = cursor.getPath();
const edit = cursor.getComponent();
const scopedSegment = ['headers', 'footers', 'notes'].includes(String(path[0])) && typeof path[1] === 'string'
? path[1]
: segmentId;
if (path[0] === 'notes' && typeof path[1] === 'string') {
getSectionPermissionObjectIds(documentDataModel, path[1], { startOffset: 0, endOffset: 0 })
.forEach((objectId) => result.add(objectId));
}
if (path[0] === 'noteSettings') {
getDocumentPermissionResolverIndex(documentDataModel).topLevelSections.forEach((section) => {
result.add(getDocumentSectionPermissionObjectId('', section.sectionId));
});
}
if (path[0] === 'body' && path[1] === 'sectionBreaks' && typeof path[2] === 'number' && (path[3] === 'noteProperties' || path[3] === 'suppressEndnotes')) {
const sectionId = documentDataModel.getBody()?.sectionBreaks?.[path[2]]?.sectionId;
if (sectionId) {
result.add(getDocumentSectionPermissionObjectId('', sectionId));
}
}
if (path[path.length - 1] !== 'body' || edit?.et !== TextX.id || !Array.isArray(edit.e) || !edit.e.every(isTextXAction)) {
return;
}
const textActions = edit.e;
let offset = 0;
const addRange = (startOffset: number, endOffset: number): void => {
getDocumentEditTargetObjectIds(documentDataModel, segmentId, { startOffset, endOffset })
getDocumentEditTargetObjectIds(documentDataModel, scopedSegment, { startOffset, endOffset })
.forEach((objectId) => result.add(objectId));
};
@@ -203,7 +246,7 @@ export function getDocumentEditTargetObjectIdsFromActions(
}
offset += action.len;
});
}
});
getDrawingIdsFromActions(actions).forEach((drawingId) => {
result.add(getDocumentEntityPermissionObjectId(segmentId, 'drawing', drawingId));
@@ -241,14 +284,6 @@ function getDrawingIdsFromActions(actions: JSONXActions): string[] {
return [...result];
}
function getTextXActions(actions: JSONXActions): TextXAction[] | null {
if (!Array.isArray(actions) || actions.length !== 2 || actions[0] !== 'body' || !isRecord(actions[1])) {
return null;
}
const edit = actions[1];
return edit.et === TextX.id && Array.isArray(edit.e) && edit.e.every(isTextXAction) ? edit.e : null;
}
function isTextXAction(value: unknown): value is TextXAction {
if (!isRecord(value) || typeof value.len !== 'number') {
return false;
@@ -313,6 +348,12 @@ function getSectionPermissionObjectIds(
resolverIndex = getDocumentPermissionResolverIndex(documentDataModel)
): string[] {
if (segmentId) {
const referenceRange = resolverIndex.footnoteReferenceRanges.get(segmentId);
if (referenceRange) {
// A note belongs to its reference, including the enclosing paragraph,
// table and section. Moving the reference moves those restrictions too.
return getDocumentEditTargetObjectIds(documentDataModel, '', referenceRange);
}
return getHeaderFooterOwnerSectionIds(documentDataModel, segmentId, resolverIndex.topLevelSections)
.map((sectionId) => getDocumentSectionPermissionObjectId('', sectionId));
}
@@ -84,6 +84,7 @@ export enum DocumentSkeletonPageType {
HEADER,
FOOTER,
CELL,
NOTE,
};
export interface IDocumentSkeletonPage {
@@ -125,6 +126,12 @@ export interface IDocumentSkeletonPage {
isExplicitPageBreak?: boolean;
/** Internal layout provenance for a page opened because content exhausted the previous page. */
isNaturalPageOverflow?: boolean;
/** Note fragments use their own segment offsets, independent of body character indices. */
notes?: IDocumentSkeletonNote[];
/** Space reserved on this body page for footnotes, including separators and continuation notices. */
footnoteHeight?: number;
/** Positioned separators and continuation notices for both footnotes and endnotes. */
noteDecorations?: IDocumentSkeletonNoteDecoration[];
st: number; // startIndex
ed: number; // endIndex
/** Whether this cell page is only a layout placeholder covered by a merged cell. */
@@ -135,7 +142,26 @@ export interface IDocumentSkeletonPage {
segmentId: string; // header/footer id if header/footer, empty string if body page
type: DocumentSkeletonPageType; // page type: header, footer, body, or cell
renderConfig?: IDocumentRenderConfig;
parent?: IDocumentSkeletonCached | IDocumentSkeletonRow | IDocumentSkeletonColumnGroupColumn;
parent?: IDocumentSkeletonCached | IDocumentSkeletonRow | IDocumentSkeletonColumnGroupColumn | IDocumentSkeletonNote;
}
export interface IDocumentSkeletonNoteDecoration {
noteType?: 'footnote' | 'endnote';
kind: 'separator' | 'continuationSeparator' | 'continuationNotice';
left: number;
top: number;
page: IDocumentSkeletonPage;
}
export interface IDocumentSkeletonNote {
noteType?: 'footnote' | 'endnote';
noteId: string;
referenceIndex: number;
continued: boolean;
left: number;
top: number;
page: IDocumentSkeletonPage;
parent?: IDocumentSkeletonPage;
}
export interface IDocumentSkeletonHeaderFooter extends IDocumentSkeletonPage {}
@@ -297,6 +323,8 @@ export interface IDocumentSkeletonGlyph {
url?: string; // image url
featureId?: string; // support interaction for feature ,eg. hyperLine person
drawingId?: string; // drawing.drawingId
noteId?: string;
noteSeparator?: boolean;
fauxBoldStrokeWidth?: number;
tabLeader?: TabStopLeader;
}
@@ -359,7 +387,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
normalLineHeight?: number; // Normal font spacing, including leading when available, for Word AUTO spacing.
aba: number; // actualBoundingBoxAscent
abd: number; // actualBoundingBoxDescent
sp: number; // strikeoutPosition
@@ -113,6 +113,9 @@ export interface IFooterIds {
export interface ISectionBreakConfig extends IDocStyleBase, ISectionBreakBase, IDocsConfig {
sectionId: string;
/** Cell continuation pages retain their segment and physical row-slice budgets. */
cellTableId?: string;
cellPageHeights?: readonly number[];
headerIds?: IHeaderIds;
footerIds?: IFooterIds;
useFirstPageHeaderFooter?: BooleanNumber;
@@ -14,7 +14,7 @@
* limitations under the License.
*/
import { ColumnSeparatorType, DashStyleType, DocumentFlavor } from '@univerjs/core';
import { ColumnSeparatorType, CustomRangeType, DashStyleType, DocumentFlavor } from '@univerjs/core';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { setupRenderTestEnv } from '../../../__tests__/render-test-utils';
import {
@@ -34,6 +34,8 @@ import { DocBackground } from '../doc-background';
import { DOCS_EXTENSION_TYPE } from '../doc-extension';
import { Documents, drawSectionColumnSeparators, resolveHeaderFooterFieldGlyph } from '../document';
import { getDocumentCompatibilityPolicy } from '../document-compatibility';
import { createParagraphLayoutTestBed } from '../layout/block/paragraph/__tests__/create-paragraph-layout-test-bed';
import { DocumentSkeleton } from '../layout/doc-skeleton';
import { setDocsTableRenderViewportProvider } from '../table-render-viewport';
function createGlyph(content: string, left: number, width = 16, backgroundColor?: string) {
@@ -370,6 +372,60 @@ describe('documents render', () => {
setDocsTableRenderViewportProvider(null);
});
it.each(['separator', 'continuationSeparator', 'continuationNotice'] as const)('renders each note kind with its own %s fields', (kind) => {
const fieldBody = (fieldType: string) => ({
dataStream: '\uFFFC\r\n',
paragraphs: [{ startIndex: 1, paragraphId: 'field' }],
sectionBreaks: [{ startIndex: 2, sectionId: 'field-section' }],
customRanges: [{ rangeId: fieldType, rangeType: CustomRangeType.FIELD, startIndex: 0, endIndex: 0, properties: { fieldType } }],
});
const footnoteBody = fieldBody('PAGE');
const endnoteBody = fieldBody('NUMPAGES');
const bed = createParagraphLayoutTestBed('Reference\uFFFC\uFFFC', {
documentStyle: { documentFlavor: DocumentFlavor.TRADITIONAL },
body: {
customRanges: ['footnote', 'endnote'].map((type, index) => ({
rangeId: type,
rangeType: index === 0 ? CustomRangeType.FOOTNOTE : CustomRangeType.ENDNOTE,
startIndex: 9 + index,
endIndex: 9 + index,
wholeEntity: true,
properties: { noteId: type },
})),
},
notes: Object.fromEntries(['footnote', 'endnote'].map((type) => [type, { noteId: type, type, body: fieldBody('PAGE') }])),
noteSettings: { footnote: { [kind]: footnoteBody }, endnote: { [kind]: endnoteBody } },
});
const skeleton = DocumentSkeleton.create(bed.viewModel, bed.ctx.docsConfig.localeService);
const documents = new Documents('note-fields', skeleton);
try {
skeleton.calculate();
const page = skeleton.getSkeletonData()!.pages[0];
const fragments = page.notes!;
// Render both kinds on the same body page, including continuation decorations.
page.noteDecorations = fragments.map((note) => ({
noteType: bed.dataModel.getSnapshot().notes![note.noteId].type,
kind,
left: note.left,
top: note.top,
page: note.page,
}));
const render = vi.spyOn(documents as unknown as { _drawHeaderFooter: (...args: unknown[]) => void }, '_drawHeaderFooter');
documents.draw(canvas.getContext());
for (const decoration of page.noteDecorations) {
const call = render.mock.calls.find((args) => args[0] === decoration.page);
expect(call).toBeDefined();
expect(call![14]).toEqual(decoration.noteType === 'endnote' ? endnoteBody.customRanges : footnoteBody.customRanges);
}
expect(page.noteDecorations.map((note) => note.noteType).sort()).toEqual(['endnote', 'footnote']);
} finally {
documents.dispose();
skeleton.dispose();
bed.viewModel.dispose();
bed.dataModel.dispose();
}
});
it('hit tests table content and controls that overflow the document bounds', () => {
const bodyPage = createPage(DocumentSkeletonPageType.BODY, '');
attachTable(bodyPage);
@@ -41,11 +41,19 @@ import type { ComponentExtension, IDrawInfo, IExtensionConfig } from '../extensi
import type { IDocumentsConfig, IPageMarginLayout } from './doc-component';
import type { DocumentSkeleton } from './layout/doc-skeleton';
import type { IDocsTableRenderViewport } from './table-render-viewport';
import { CellValueType, ColumnSeparatorType, DashStyleType, DocumentFlavor, HorizontalAlign, VerticalAlign, WrapStrategy } from '@univerjs/core';
import {
CellValueType,
ColumnSeparatorType,
DashStyleType,
DocumentFlavor,
HorizontalAlign,
VerticalAlign,
WrapStrategy,
} from '@univerjs/core';
import { Subject } from 'rxjs';
import { BORDER_TYPE, COLOR_BLACK_RGB, drawLineByBorderType } from '../../basics';
import { calculateRectRotate, getRotateOffsetAndFarthestHypotenuse } from '../../basics/draw';
import { LineType } from '../../basics/i-document-skeleton-cached';
import { DocumentSkeletonPageType, LineType } from '../../basics/i-document-skeleton-cached';
import { VERTICAL_ROTATE_ANGLE } from '../../basics/text-rotation';
import { degToRad, fixLineWidthByScale } from '../../basics/tools';
import { Vector2 } from '../../basics/vector2';
@@ -58,7 +66,11 @@ import { getTableIdAndSliceIndex } from './layout/block/table';
import { getColorStyleForCanvas } from './layout/style/color';
import { documentSkeletonTableIterator } from './layout/tools';
import { Liquid } from './liquid';
import { getDocsTableRenderViewport, getDocsTableViewportLeft, hasDocsTableHorizontalViewport } from './table-render-viewport';
import {
getDocsTableRenderViewport,
getDocsTableViewportLeft,
hasDocsTableHorizontalViewport,
} from './table-render-viewport';
import './extensions';
const DEFAULT_BORDER_COLOR: ITableCellBorder = {
@@ -671,6 +683,28 @@ export class Documents extends DocComponent {
this._resetRotation(ctx, finalAngle);
for (const note of [...page.noteDecorations ?? [], ...page.notes ?? []]) {
this._drawHeaderFooter(
note.page,
ctx,
extensions,
backgroundExtension,
preTextBackgroundExtensions,
glyphExtensionsExcludeBackground,
Vector2.create(horizontalOffsetNoAngle + note.left - page.marginLeft, note.top),
centerAngle,
vertexAngle,
renderConfig,
parentScale,
page,
false,
pages.length,
'kind' in note
? this.getSkeleton()?.getViewModel().getSnapshot().noteSettings?.[note.noteType ?? 'footnote']?.[note.kind]?.customRanges ?? []
: undefined
);
}
const footerSkeletonPage = skeFooters.get(footerId)?.get(pageWidth);
if (footerSkeletonPage) {
@@ -1492,7 +1526,8 @@ export class Documents extends DocComponent {
parentScale: IScale,
parentPage: IDocumentSkeletonPage,
isHeader = true,
pageCount = 1
pageCount = 1,
customRangesOverride?: ICustomRange[]
) {
if (this._drawLiquid == null) {
return;
@@ -1500,13 +1535,13 @@ export class Documents extends DocComponent {
const { sections, skeTables } = page;
const { y: originY } = this._drawLiquid;
const skeleton = this.getSkeleton();
const customRanges = typeof skeleton?.getViewModel === 'function'
const customRanges = customRangesOverride ?? (typeof skeleton?.getViewModel === 'function'
? skeleton
.getViewModel()
.getSelfOrHeaderFooterViewModel(page.segmentId)
.getBody()
?.customRanges ?? []
: [];
: []);
if (skeTables.size > 0) {
const tablePage = {
@@ -1572,7 +1607,7 @@ export class Documents extends DocComponent {
this._drawLiquid.translateLine(line, true, true);
const { y } = this._drawLiquid;
if (!isHeader) {
if (!isHeader && page.type !== DocumentSkeletonPageType.NOTE) {
if ((y - originY + alignOffset.y + lineHeight) < (parentPage.pageHeight - 100) / 2 + 100) {
this._drawLiquid.translateRestore();
continue;
@@ -23,7 +23,7 @@ import { describe, expect, it, vi } from 'vitest';
import { COLOR_BLACK_RGB } from '../../../../basics/const';
import { GlyphType } from '../../../../basics/i-document-skeleton-cached';
import { Vector2 } from '../../../../basics/vector2';
import { CheckboxShape } from '../../../../shape';
import { CheckboxShape } from '../../../../shape/checkbox';
import { FontAndBaseLine } from '../font-and-base-line';
type MockRenderContext = UniverRenderingContext & {
@@ -158,6 +158,24 @@ describe('docs font and baseline extension', () => {
expect(ScreenContext.fillStyle).toBe('#000000');
});
it('draws footnote separator marks with their measured width and text color', () => {
const extension = new FontAndBaseLine();
const SeparatorContext = Object.assign(createContext(), {
beginPath: vi.fn(),
moveToByPrecision: vi.fn(),
lineToByPrecision: vi.fn(),
stroke: vi.fn(),
});
extension.extensionOffset = { spanPointWithFont: Vector2.create(12, 20) };
extension.draw(SeparatorContext, DEFAULT_SCALE, createGlyph(' ', { noteSeparator: true, width: 192 }));
expect(SeparatorContext.moveToByPrecision).toHaveBeenCalledWith(12, 20);
expect(SeparatorContext.lineToByPrecision).toHaveBeenCalledWith(204, 20);
expect(SeparatorContext.strokeStyle).toBe('#223344');
expect(SeparatorContext.stroke).toHaveBeenCalledOnce();
expect(SeparatorContext.fillText).not.toHaveBeenCalled();
expect(SeparatorContext.restore).toHaveBeenCalledOnce();
});
it('renders public glow and outer shadow behind the source text without canvas filters', () => {
const extension = new FontAndBaseLine();
const TestContext = createContext();
@@ -108,6 +108,18 @@ export class FontAndBaseLine extends docExtension {
spanPointWithFont.y += bBox.sbo;
}
if (glyph.noteSeparator) {
ctx.save();
ctx.strokeStyle = fontColor;
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveToByPrecision(spanPointWithFont.x, spanPointWithFont.y);
ctx.lineToByPrecision(spanPointWithFont.x + glyph.width, spanPointWithFont.y);
ctx.stroke();
ctx.restore();
return;
}
const drawText = () => {
if (this._fillTextWithTextFill(ctx, glyph, spanPointWithFont, textFill, fontColor)) {
return;
@@ -15,6 +15,7 @@
*/
import type { IDocumentData, IParagraph } from '@univerjs/core';
import type { IDocumentLayoutPageGeometryPublication } from '../document-layout-publication';
import {
BooleanNumber,
ColumnSeparatorType,
@@ -54,6 +55,7 @@ import { setDocsCustomBlockRenderViewportProvider } from '../../custom-block-ren
import { setDocsTableRenderViewportProvider } from '../../table-render-viewport';
import { DocumentViewModel } from '../../view-model/document-view-model';
import { DocumentSkeleton } from '../doc-skeleton';
import { serializeDocumentSkeletonPage } from '../document-layout-page-patch';
import { Hyphen } from '../hyphenation/hyphen';
import { Lang } from '../hyphenation/lang';
import { PATTERN_LOADERS } from '../hyphenation/pattern-loaders.gen';
@@ -219,6 +221,261 @@ function createPage(type: DocumentSkeletonPageType, st: number, tableId = '') {
}
describe('doc skeleton', () => {
it.each([2, 6].flatMap((paragraphCount) => [TableRowHeightRule.AUTO, TableRowHeightRule.AT_LEAST, TableRowHeightRule.EXACT].map((hRule) => ({ paragraphCount, hRule }))))('sizes vertically merged cells against the whole row span ($paragraphCount paragraphs, rule $hRule)', ({ paragraphCount, hRule }) => {
const measure = vi.spyOn(FontCache, 'getMeasureText').mockImplementation((text: string) => ({
width: text.length * 5,
fontBoundingBoxAscent: 8,
fontBoundingBoxDescent: 2,
actualBoundingBoxAscent: 8,
actualBoundingBoxDescent: 2,
}) as TextMetrics);
const univer = new Univer();
const T = DataStreamTreeTokenType;
const cell = (text: string) => `${T.TABLE_CELL_START}${text}\n${T.TABLE_CELL_END}`;
const row = (left: string, right: string) => `${T.TABLE_ROW_START}${cell(left)}${cell(right)}${T.TABLE_ROW_END}`;
const tableStream = `${T.TABLE_START}${row('Merged\r'.repeat(paragraphCount), 'One\r')}${row('\r', 'Two\r')}${T.TABLE_END}`;
const dataStream = `${tableStream}\rAfter\r\n`;
const snapshot: IDocumentData = {
id: 'merged-cell-height',
body: {
dataStream,
paragraphs: [...dataStream.matchAll(/\r/g)].filter((match) => match.index !== tableStream.length).map((match, index) => ({ paragraphId: `p-${index}`, startIndex: match.index })),
sectionBreaks: [...dataStream.matchAll(/\n/g)].map((match, index) => ({ sectionId: `s-${index}`, startIndex: match.index })),
tables: [{ tableId: 'table', startIndex: 0, endIndex: tableStream.length }],
},
documentStyle: {
documentFlavor: DocumentFlavor.TRADITIONAL,
pageSize: { width: 400, height: 400 },
marginTop: 10,
marginBottom: 10,
marginLeft: 10,
marginRight: 10,
},
tableSource: { table: {
tableId: 'table',
align: TableAlignmentType.START,
indent: { v: 0 },
textWrap: TableTextWrapType.NONE,
size: { type: TableSizeType.SPECIFIED, width: { v: 200 } },
position: {
positionH: { relativeFrom: ObjectRelativeFromH.PAGE },
positionV: { relativeFrom: ObjectRelativeFromV.PAGE },
},
dist: { distT: 0, distB: 0, distL: 0, distR: 0 },
cellMargin: { top: { v: 0 }, bottom: { v: 0 }, start: { v: 0 }, end: { v: 0 } },
tableColumns: [{ size: { type: TableSizeType.SPECIFIED, width: { v: 100 } } }, { size: { type: TableSizeType.SPECIFIED, width: { v: 100 } } }],
tableRows: [0, 1].map((index) => ({
trHeight: { hRule, val: { v: 25 } },
tableCells: [{ rowSpan: index === 0 ? 2 : 0, vAlign: VerticalAlignmentType.CENTER }, {}],
})),
} },
};
const viewModel = new DocumentViewModel(new DocumentDataModel(snapshot));
const skeleton = DocumentSkeleton.create(viewModel, univer.__getInjector().get(LocaleService));
try {
skeleton.calculate();
const pages = skeleton.getSkeletonData()!.pages;
expect(pages).toHaveLength(1);
const table = pages[0].skeTables.get('table')!;
const mergedCell = table.rows[0].cells[0];
const otherCellsHeight = table.rows.reduce((height, row) => height + row.cells[1].height, 0);
let expectedHeight = Math.max(mergedCell.height, otherCellsHeight);
if (hRule === TableRowHeightRule.EXACT) {
expectedHeight = 50;
} else if (hRule === TableRowHeightRule.AT_LEAST) {
expectedHeight = Math.max(expectedHeight, 50);
}
expect(table.height).toBeCloseTo(expectedHeight);
expect(mergedCell.pageHeight).toBeCloseTo(table.height);
expect(mergedCell.marginTop).toBeCloseTo(Math.max(0, (table.height - mergedCell.height) / 2));
expect(table.rows[1].top).toBeCloseTo(table.rows[0].height);
expectIncrementalSkeletonToEqualSynchronous(snapshot, univer.__getInjector().get(LocaleService));
} finally {
skeleton.dispose();
viewModel.dispose();
univer.dispose();
measure.mockRestore();
}
});
it('reserves footnote space and carries long notes through synchronous and sliced pagination', () => {
const measure = vi.spyOn(FontCache, 'getMeasureText').mockImplementation((text: string) => ({
width: text.length * 5,
fontBoundingBoxAscent: 8,
fontBoundingBoxDescent: 2,
actualBoundingBoxAscent: 8,
actualBoundingBoxDescent: 2,
}) as TextMetrics);
const univer = new Univer();
const dataStream = `${'Intro\r'.repeat(30)}Text with reference\uFFFC and more text.\r${'Later body paragraph.\r'.repeat(10)}\n`;
const noteStream = `${'Long explanation that continues onto the following page.\r'.repeat(10)}\n`;
const referenceIndex = dataStream.indexOf('\uFFFC');
const snapshot: IDocumentData = {
id: 'footnote-pagination',
body: {
dataStream,
paragraphs: [...dataStream.matchAll(/\r/g)].map((match, i) => ({ startIndex: match.index!, paragraphId: `p-${i}` })),
sectionBreaks: [{ startIndex: dataStream.length - 1, sectionId: 'main' }],
customRanges: [{
rangeType: CustomRangeType.FOOTNOTE,
rangeId: 'ref',
wholeEntity: true,
startIndex: referenceIndex,
endIndex: referenceIndex,
properties: { noteId: 'note' },
}],
},
notes: { note: { type: 'footnote' as const, noteId: 'note', body: {
dataStream: noteStream,
paragraphs: [...noteStream.matchAll(/\r/g)].map((match, i) => ({ startIndex: match.index!, paragraphId: `n-${i}` })),
sectionBreaks: [{ startIndex: noteStream.length - 1, sectionId: 'note' }],
} } },
documentStyle: {
documentFlavor: DocumentFlavor.TRADITIONAL,
pageSize: { width: 220, height: 160 },
marginTop: 10,
marginBottom: 10,
marginLeft: 10,
marginRight: 10,
},
};
const skeleton = DocumentSkeleton.create(new DocumentViewModel(new DocumentDataModel(snapshot)), univer.__getInjector().get(LocaleService));
try {
skeleton.calculate();
const pages = skeleton.getSkeletonData()!.pages;
const fragments = pages.flatMap((page) => page.notes ?? []);
expect(fragments.length).toBeGreaterThan(1);
const referencePage = pages.find((page) => page.st <= referenceIndex && page.ed >= referenceIndex)!;
expect(referencePage.notes?.[0]).toMatchObject({ noteId: 'note', continued: false });
expect(fragments.slice(1).every((fragment) => fragment.continued)).toBe(true);
const firstPosition = skeleton.findNodePositionByCharIndex(0, true, 'note');
expect(firstPosition?.pageType).toBe(DocumentSkeletonPageType.NOTE);
expect(firstPosition?.path).toContain('notes');
expect(skeleton.findCharIndexByPosition(firstPosition!)).toBe(0);
expect(skeleton.findGlyphByPosition(firstPosition)).toBe(skeleton.findNodeByCharIndex(0, 'note'));
const referencePageIndex = pages.indexOf(referencePage);
const continuation = fragments[1];
const continuationPosition = skeleton.findNodePositionByCharIndex(continuation.page.st, true, 'note', referencePageIndex);
expect(continuationPosition?.page).toBeGreaterThan(referencePageIndex);
expect(skeleton.findCharIndexByPosition(continuationPosition!)).toBe(continuation.page.st);
const pageTop = pages.slice(0, referencePageIndex).reduce((top, page) => top + page.pageHeight, 0);
const firstFragment = referencePage.notes![0];
const hit = skeleton.findNodeByCoord(Vector2.create(firstFragment.left + 20, pageTop + firstFragment.top + 5), PageLayoutType.VERTICAL, 0, 0);
expect(hit?.segmentId).toBe('note');
expect(hit?.segmentPage).toBe(referencePageIndex);
for (const page of pages) {
const firstNote = page.notes?.[0];
if (!firstNote) {
continue;
}
expect(page.height + page.marginTop).toBeLessThanOrEqual(firstNote.top);
expect(firstNote.top + firstNote.page.height).toBeLessThanOrEqual(page.pageHeight - page.marginBottom + 2);
}
expect(fragments.map((fragment) => fragment.page.sections.flatMap((section) => section.columns.flatMap(
(column) => column.lines.flatMap((line) => line.divides.flatMap((divide) => divide.glyphGroup.map((glyph) => glyph.raw)))
)).join('')).join('')).toBe(noteStream);
expectIncrementalSkeletonToEqualSynchronous(snapshot, univer.__getInjector().get(LocaleService));
const edited = structuredClone(snapshot);
const added = 'Expanded note content.\r'.repeat(3);
const editedBody = edited.notes!.note.body;
editedBody.dataStream = added + noteStream;
editedBody.paragraphs = [...editedBody.dataStream.matchAll(/\r/g)].map((match, index) => ({
paragraphId: `edited-note-${index}`,
startIndex: match.index!,
}));
editedBody.sectionBreaks![0].startIndex = editedBody.dataStream.length - 1;
skeleton.getViewModel().reset(new DocumentDataModel(edited));
const generation = skeleton.startIncrementalLayout({
reason: 'edit',
anchor: referenceIndex,
invalidation: { oldStart: referenceIndex, oldEnd: referenceIndex + 1, newEnd: referenceIndex + 1 },
});
let progress = skeleton.stepIncrementalLayout(generation, 0);
for (let step = 0; !progress.complete && step < 1_000; step++) {
progress = skeleton.stepIncrementalLayout(generation, 0);
}
expect(progress.complete).toBe(true);
const expected = DocumentSkeleton.create(new DocumentViewModel(new DocumentDataModel(edited)), univer.__getInjector().get(LocaleService));
try {
expected.calculate();
expect(normalizeSkeleton(skeleton.getSkeletonData())).toEqual(normalizeSkeleton(expected.getSkeletonData()));
const data = skeleton.getSkeletonData()!;
const notePageIndex = data.pages.findIndex((page) => page.notes?.length);
const protectedPage = data.pages[notePageIndex];
const publication: IDocumentLayoutPageGeometryPublication = {
kind: 'page',
left: data.left,
top: data.top,
st: data.st,
resources: { reset: false, skeHeaders: [], skeFooters: [], skeListLevel: null, drawingAnchor: null },
pages: data.pages.map((page, pageIndex) => ({ pageIndex, page: serializeDocumentSkeletonPage(page) })),
};
skeleton.beginExternalLayout({
reason: 'edit',
protectedRange: { mode: 'paginated', startPageIndex: notePageIndex, endPageIndex: notePageIndex },
});
// A Worker can agree on body pagination but move a footnote line
// to the next page. That boundary must take part in the merge.
publication.pages[notePageIndex].page.notes![0].page.ed -= 1;
const partial = { ...progress, complete: false };
expect(skeleton.applyLayoutPublication(publication, partial).didReplaceProtectedPages).toBe(false);
expect(skeleton.getSkeletonData()!.pages[notePageIndex]).toBe(protectedPage);
expect(skeleton.applyLayoutPublication({ ...publication, pages: [] }, progress).didReplaceProtectedPages).toBe(true);
expect(skeleton.getSkeletonData()!.pages[notePageIndex].notes![0].page.ed)
.toBe(protectedPage.notes![0].page.ed - 1);
} finally {
expected.dispose();
}
} finally {
measure.mockRestore();
skeleton.dispose();
univer.dispose();
}
});
it('shapes a multi-character footnote number as one editable reference in both layout paths', () => {
const univer = new Univer();
const snapshot: Partial<IDocumentData> = {
id: 'footnote-caret',
documentStyle: { documentFlavor: DocumentFlavor.TRADITIONAL, pageSize: { width: 400, height: 500 } },
body: {
dataStream: 'A\uFFFCB\r\n',
paragraphs: [{ startIndex: 3, paragraphId: 'paragraph' }],
sectionBreaks: [{ startIndex: 4, sectionId: 'section' }],
customRanges: [{
rangeId: 'reference',
rangeType: CustomRangeType.FOOTNOTE,
startIndex: 1,
endIndex: 1,
wholeEntity: true,
properties: { noteId: 'note' },
}],
},
notes: { note: { type: 'footnote' as const, noteId: 'note', body: {
dataStream: 'Explanation\r\n',
paragraphs: [{ startIndex: 11, paragraphId: 'note-paragraph' }],
sectionBreaks: [{ startIndex: 12, sectionId: 'note-section' }],
} } },
noteSettings: { footnote: { startNumber: 8, numberFormat: 'upperRoman' } },
};
const model = new DocumentDataModel(snapshot);
const viewModel = new DocumentViewModel(model);
const localeService = univer.__getInjector().get(LocaleService);
const skeleton = DocumentSkeleton.create(viewModel, localeService);
try {
skeleton.calculate();
expect(skeleton.findNodeByCharIndex(1)).toMatchObject({ content: 'VIII', raw: '\uFFFC', count: 1 });
expect(skeleton.findNodeByCharIndex(2)).toMatchObject({ content: 'B' });
expect(skeleton.findNodePositionByCharIndex(2)?.page).toBe(0);
expectIncrementalSkeletonToEqualSynchronous(snapshot, localeService);
} finally {
skeleton.dispose();
viewModel.dispose();
model.dispose();
univer.dispose();
}
});
it.each(['ready', 'cancel', 'failure', 'header', 'footer'])('waits for cold hyphenation rules and matches a warm executor (%s)', async (scenario) => {
const univer = new Univer();
const content = scenario === 'header' || scenario === 'footer'
@@ -975,19 +1232,10 @@ describe('doc skeleton', () => {
const header = createPage(DocumentSkeletonPageType.HEADER, 200);
const footer = createPage(DocumentSkeletonPageType.FOOTER, 300);
const docViewModel = {
getDataModel: () => ({
documentStyle: {
pageSize: { width: 210, height: 297 },
},
}),
getHeaderFooterTreeMap: () => ({
headerTreeMap: new Map(),
footerTreeMap: new Map(),
}),
dispose: vi.fn(),
} as any;
const skeleton = new DocumentSkeleton(docViewModel, {} as any);
const univer = new Univer();
const model = createDocumentModelWithStyle('ABC', {});
model.updateDocumentStyle({ pageSize: { width: 210, height: 297 } });
const skeleton = DocumentSkeleton.create(new DocumentViewModel(model), univer.__getInjector().get(LocaleService));
(skeleton as any)._skeletonData = {
pages: [body.page],
skeHeaders: new Map([['header-seg', new Map([[body.page.pageWidth, header.page]])]]),
@@ -1058,6 +1306,9 @@ describe('doc skeleton', () => {
(skeleton as any)._translatePage(body.page, PageLayoutType.VERTICAL, 1, 2);
expect((skeleton as any)._findLiquid.x).toBeGreaterThanOrEqual(0);
skeleton.dispose();
model.dispose();
univer.dispose();
});
it('finds nodes by coordinate inside column group columns', () => {
@@ -1446,12 +1697,12 @@ describe('doc skeleton', () => {
const localeService = univer.__getInjector().get(LocaleService);
const viewModel = new DocumentViewModel(documentModel);
const skeleton = DocumentSkeleton.create(viewModel, localeService);
const createSkeletonSpy = vi.spyOn(skeleton as any, '_createSkeleton');
const createSkeletonPassSpy = vi.spyOn(skeleton as any, '_createSkeletonPass');
const restoreContinuousSectionSpy = vi.spyOn(skeleton as any, '_restoreContinuousSection');
skeleton.calculate();
expect(createSkeletonSpy.mock.calls.length).toBeGreaterThan(1);
expect(createSkeletonPassSpy.mock.calls.length).toBeGreaterThan(1);
expect(restoreContinuousSectionSpy).not.toHaveBeenCalled();
const firstPageSections = skeleton.getSkeletonData()?.pages[0]?.sections ?? [];
expect(firstPageSections).toHaveLength(2);
@@ -1542,11 +1793,11 @@ describe('doc skeleton', () => {
const univer = new Univer();
const localeService = univer.__getInjector().get(LocaleService);
const skeleton = DocumentSkeleton.create(new DocumentViewModel(documentModel), localeService);
const createSkeletonSpy = vi.spyOn(skeleton as any, '_createSkeleton');
const createSkeletonPassSpy = vi.spyOn(skeleton as any, '_createSkeletonPass');
skeleton.calculate();
expect(createSkeletonSpy.mock.calls.length).toBeGreaterThan(1);
expect(createSkeletonPassSpy.mock.calls.length).toBeGreaterThan(1);
expect(skeleton.getSkeletonData()?.pages.map(({ pageNumber }) => pageNumber)).toEqual([1, 2]);
const incremental = DocumentSkeleton.create(new DocumentViewModel(documentModel), localeService);
@@ -0,0 +1,146 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { IDocumentData, IDocumentNote } from '@univerjs/core';
import { CustomRangeType, DocumentFlavor } from '@univerjs/core';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { createParagraphLayoutTestBed } from '../block/paragraph/__tests__/create-paragraph-layout-test-bed';
import { DocumentSkeleton } from '../doc-skeleton';
import { hydrateDocumentSkeletonPage, serializeDocumentSkeletonPage } from '../document-layout-page-patch';
import { resolveNoteReferences } from '../note-numbering';
import { FontCache } from '../shaping-engine/font-cache';
function body(text: string) {
const dataStream = `${text}\r\n`;
return { dataStream, paragraphs: [...dataStream.matchAll(/\r/g)].map((match, i) => ({ startIndex: match.index!, paragraphId: `p-${i}` })), sectionBreaks: [...dataStream.matchAll(/\n/g)].map((match, i) => ({ startIndex: match.index!, sectionId: `s-${i}` })) };
}
function snapshot(position: 'docEnd' | 'sectEnd', long = false) {
const main = body('First \uFFFC foot \uFFFC\r\nSecond \uFFFC');
const types = ['endnote', 'footnote', 'endnote'] as const;
const notes: Record<string, IDocumentNote> = {};
const customRanges = [...main.dataStream.matchAll(/\uFFFC/g)].map((match, i) => {
const noteId = `note-${i}`;
notes[noteId] = { noteId, type: types[i], body: body(long && i === 0 ? Array.from({ length: 60 }, (_, i) => `Long note paragraph ${i}`).join('\r') : `Note ${i}`) };
return { rangeId: `ref-${i}`, rangeType: types[i] === 'endnote' ? CustomRangeType.ENDNOTE : CustomRangeType.FOOTNOTE, startIndex: match.index!, endIndex: match.index!, wholeEntity: true, properties: { noteId } };
});
return { documentStyle: { documentFlavor: DocumentFlavor.TRADITIONAL, pageSize: { width: 300, height: 180 }, marginTop: 10, marginBottom: 10, marginLeft: 10, marginRight: 10 }, body: { ...main, customRanges }, notes, noteSettings: { endnote: { position, numberFormat: 'lowerRoman' }, footnote: { startNumber: 3 } } } satisfies Partial<IDocumentData>;
}
describe('endnote pagination', () => {
beforeEach(() => {
vi.spyOn(FontCache, 'getMeasureText').mockImplementation((text: string) => ({ width: text.length * 5, fontBoundingBoxAscent: 8, fontBoundingBoxDescent: 2, actualBoundingBoxAscent: 8, actualBoundingBoxDescent: 2 }) as TextMetrics);
});
afterEach(() => vi.restoreAllMocks());
it('numbers footnotes and endnotes independently', () => {
const refs = [...resolveNoteReferences(snapshot('docEnd')).values()];
expect(refs.map((ref) => ref.label)).toEqual(['i', '3', 'ii']);
});
it.each([false, true])('places section endnotes before the next section and document endnotes after it (incremental: %s)', (incremental) => {
for (const position of ['sectEnd', 'docEnd'] as const) {
const bed = createParagraphLayoutTestBed('', snapshot(position));
const skeleton = DocumentSkeleton.create(bed.viewModel, bed.ctx.docsConfig.localeService);
try {
if (incremental) {
const generation = skeleton.startIncrementalLayout();
let progress = skeleton.stepIncrementalLayout(generation, 0);
for (let i = 0; i < 1000 && !progress.complete; i++) {
progress = skeleton.stepIncrementalLayout(generation, 0);
}
expect(progress.complete).toBe(true);
} else {
skeleton.calculate();
}
const pages = skeleton.getSkeletonData()!.pages;
const firstNote = pages.flatMap((page, pageIndex) => (page.notes ?? []).map((note) => ({ ...note, pageIndex }))).find((note) => note.noteId === 'note-0')!;
const secondSectionPage = pages.findIndex((page) => page.sections.some((section) => section.columns.some((column) => column.lines.some((line) => line.paragraphIndex > 16))));
expect(firstNote).toBeDefined();
expect(pages.flatMap((page) => page.notes ?? []).filter((note) => !note.continued).map((note) => note.noteId).sort()).toEqual(['note-0', 'note-1', 'note-2']);
if (position === 'sectEnd') {
expect(firstNote.pageIndex).toBeLessThan(secondSectionPage);
} else {
expect(firstNote.pageIndex).toBeGreaterThanOrEqual(secondSectionPage);
}
for (const page of pages) {
const restored = hydrateDocumentSkeletonPage(serializeDocumentSkeletonPage(page));
expect(restored.notes?.map((note) => note.noteId)).toEqual(page.notes?.map((note) => note.noteId));
}
} finally {
skeleton.dispose();
bed.viewModel.dispose();
bed.dataModel.dispose();
}
}
});
it('defers suppressed section endnotes while retaining their numbering and reference order', () => {
const data = snapshot('sectEnd');
Object.assign(data.body.sectionBreaks[0], { suppressEndnotes: true });
const bed = createParagraphLayoutTestBed('', data);
const skeleton = DocumentSkeleton.create(bed.viewModel, bed.ctx.docsConfig.localeService);
try {
skeleton.calculate();
const pages = skeleton.getSkeletonData()!.pages;
const endnotes = pages.flatMap((page, pageIndex) => (page.notes ?? [])
.filter((note) => note.noteType === 'endnote')
.map((note) => ({ noteId: note.noteId, pageIndex })));
expect(endnotes.map((note) => note.noteId)).toEqual(['note-0', 'note-2']);
expect(endnotes[0].pageIndex).toBe(endnotes[1].pageIndex);
expect(endnotes[0].pageIndex).toBeGreaterThan(0);
} finally {
skeleton.dispose();
bed.viewModel.dispose();
bed.dataModel.dispose();
}
});
it.each([false, true])('splits long endnotes and keeps all content above the footer (incremental: %s)', (incremental) => {
const bed = createParagraphLayoutTestBed('', snapshot('docEnd', true));
const skeleton = DocumentSkeleton.create(bed.viewModel, bed.ctx.docsConfig.localeService);
try {
if (incremental) {
const generation = skeleton.startIncrementalLayout();
let progress = skeleton.stepIncrementalLayout(generation, 0);
for (let i = 0; i < 1000 && !progress.complete; i++) {
progress = skeleton.stepIncrementalLayout(generation, 0);
}
expect(progress.complete).toBe(true);
} else {
skeleton.calculate();
}
const pages = skeleton.getSkeletonData()!.pages;
const notes = pages.flatMap((page) => page.notes ?? []).filter((note) => note.noteId === 'note-0');
expect(notes.length).toBeGreaterThan(1);
const text = notes.flatMap((note) => note.page.sections.flatMap((section) => section.columns.flatMap((column) => column.lines.flatMap((line) => line.divides.flatMap((divide) => divide.glyphGroup.map((glyph) => glyph.raw ?? glyph.content)))))).join('');
for (let i = 0; i < 60; i++) {
expect(text).toContain(`Long note paragraph ${i}`);
}
expect(notes[0].continued).toBe(false);
expect(notes.slice(1).every((note) => note.continued)).toBe(true);
for (const page of pages) {
for (const note of page.notes ?? []) {
expect(note.top + note.page.height).toBeLessThanOrEqual(page.pageHeight - page.marginBottom + 0.1);
}
}
} finally {
skeleton.dispose();
bed.viewModel.dispose();
bed.dataModel.dispose();
}
});
});
@@ -0,0 +1,673 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { IDocumentNote } from '@univerjs/core';
import type { IDocumentSkeletonPage } from '../../../../basics/i-document-skeleton-cached';
import {
BaselineOffset,
BooleanNumber,
CustomRangeType,
DataStreamTreeTokenType,
DocumentFlavor,
} from '@univerjs/core';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { DocumentSkeletonPageType, PageLayoutType } from '../../../../basics/i-document-skeleton-cached';
import { Vector2 } from '../../../../basics/vector2';
import { createParagraphLayoutTestBed } from '../block/paragraph/__tests__/create-paragraph-layout-test-bed';
import { DocumentSkeleton } from '../doc-skeleton';
import { hydrateDocumentSkeletonPage, serializeDocumentSkeletonPage } from '../document-layout-page-patch';
import { layoutNoteBody } from '../footnote-layout';
import { resolveNoteReferences } from '../note-numbering';
import { FontCache } from '../shaping-engine/font-cache';
function createNoteTestBed(pageHeight = 600, noteOverrides: Partial<IDocumentNote> = {}) {
const dataStream = `${Array.from({ length: 30 }, (_, i) => `Explanation ${i + 1} with enough text to wrap onto another line.\r`).join('')}\n`;
const paragraphs = [...dataStream.matchAll(/\r/g)].map((match, i) => ({
paragraphId: `note-paragraph-${i}`,
startIndex: match.index!,
}));
return createParagraphLayoutTestBed('Reference\uFFFC', {
documentStyle: { documentFlavor: DocumentFlavor.TRADITIONAL, pageSize: { width: 400, height: pageHeight } },
body: {
customRanges: [{
rangeId: 'reference',
rangeType: CustomRangeType.FOOTNOTE,
startIndex: 9,
endIndex: 9,
wholeEntity: true,
properties: { noteId: 'note' },
}],
},
noteSettings: { footnote: { numberFormat: 'upperRoman', startNumber: 8 } },
notes: {
note: { type: 'footnote' as const, noteId: 'note', body: {
dataStream,
paragraphs,
sectionBreaks: [{ sectionId: 'note-body', startIndex: dataStream.length - 1 }],
}, ...noteOverrides },
},
});
}
function getPageGlyphs(page: IDocumentSkeletonPage) {
return page.sections.flatMap((section) => section.columns.flatMap((column) =>
column.lines.flatMap((line) => line.divides.flatMap((divide) => divide.glyphGroup))));
}
describe('footnote body layout', () => {
beforeEach(() => {
vi.spyOn(FontCache, 'getMeasureText').mockImplementation((text: string) => ({
width: text.length * 5,
fontBoundingBoxAscent: 8,
fontBoundingBoxDescent: 2,
actualBoundingBoxAscent: 8,
actualBoundingBoxDescent: 2,
}) as TextMetrics);
});
afterEach(() => vi.restoreAllMocks());
it.each([false, true])('fills the remaining body space with table rows after an earlier footnote (incremental: %s)', (incremental) => {
const T = DataStreamTreeTokenType;
const prefix = `Reference\uFFFC\r${'Filler\r'.repeat(7)}`;
const tableStream = `${T.TABLE_START}${Array.from({ length: 10 }, (_, index) => `${T.TABLE_ROW_START}${T.TABLE_CELL_START}Row ${index}\r\n${T.TABLE_CELL_END}${T.TABLE_ROW_END}`).join('')}${T.TABLE_END}`;
const dataStream = `${prefix}${tableStream}\rAfter\r\n`;
const bed = createParagraphLayoutTestBed('', {
documentStyle: { documentFlavor: DocumentFlavor.TRADITIONAL, pageSize: { width: 400, height: 160 }, marginTop: 10, marginBottom: 10, marginLeft: 10, marginRight: 10 },
body: {
dataStream,
paragraphs: [...dataStream.matchAll(/\r/g)].filter((match) => match.index !== prefix.length + tableStream.length).map((match, index) => ({ paragraphId: `p-${index}`, startIndex: match.index })),
sectionBreaks: [...dataStream.matchAll(/\n/g)].map((match, index) => ({ sectionId: `s-${index}`, startIndex: match.index })),
tables: [{ tableId: 'table', startIndex: prefix.length, endIndex: prefix.length + tableStream.length }],
customRanges: [{ rangeId: 'ref', rangeType: CustomRangeType.FOOTNOTE, startIndex: 9, endIndex: 9, wholeEntity: true, properties: { noteId: 'note' } }],
},
tableSource: { table: {
tableId: 'table',
align: 0,
indent: { v: 0 },
textWrap: 0,
size: { type: 0, width: { v: 200 } },
cellMargin: { top: { v: 0 }, bottom: { v: 0 }, start: { v: 0 }, end: { v: 0 } },
tableColumns: [{ size: { type: 0, width: { v: 200 } } }],
tableRows: Array.from({ length: 10 }, () => ({ tableCells: [{}], trHeight: { val: { v: 0 }, hRule: 0 } })),
} },
notes: { note: { type: 'footnote' as const, noteId: 'note', body: { dataStream: 'Explanation\r\n', paragraphs: [{ paragraphId: 'note-p', startIndex: 11 }], sectionBreaks: [{ sectionId: 'note-section', startIndex: 12 }] } } },
});
const skeleton = DocumentSkeleton.create(bed.viewModel, bed.ctx.docsConfig.localeService);
try {
if (incremental) {
const generation = skeleton.startIncrementalLayout();
let progress = skeleton.stepIncrementalLayout(generation, 0);
for (let step = 0; step < 1000 && !progress.complete; step++) {
progress = skeleton.stepIncrementalLayout(generation, 0);
}
expect(progress.complete).toBe(true);
} else {
skeleton.calculate();
}
const pages = skeleton.getSkeletonData()!.pages;
expect(pages[0].notes?.map((note) => note.noteId)).toEqual(['note']);
const firstTable = [...pages[0].skeTables.values()][0];
expect(firstTable).toBeDefined();
expect(firstTable.rows[0].index).toBe(0);
expect(firstTable.rows.length).toBeLessThan(10);
expect(pages[0].marginTop + firstTable.top + firstTable.height).toBeLessThanOrEqual(pages[0].noteDecorations![0].top + 0.01);
const rows = pages.flatMap((page) => [...page.skeTables.values()].flatMap((table) => table.rows));
expect(rows.map((row) => row.index)).toEqual(Array.from({ length: 10 }, (_, index) => index));
} finally {
skeleton.dispose();
bed.viewModel.dispose();
bed.dataModel.dispose();
}
});
it.each([1, 32].flatMap((repeatCount) => [false, true].flatMap((incremental) => ['keepLines', 'keepNext', 'widowControl'].map((constraint) => ({ repeatCount, incremental, constraint })))))('reflows page-local note markers for $constraint across $repeatCount paragraphs (incremental: $incremental)', ({ repeatCount, incremental, constraint }) => {
const prefix = constraint === 'keepNext' ? 'Filler filler filler filler\rHeading \uFFFC\r' : 'Filler \uFFFC filler filler filler\r';
const kept = constraint !== 'widowControl' ? 'One \uFFFC two three four five six seven eight nine ten.\r' : 'One two three four five six seven eight nine \uFFFC ten eleven twelve thirteen.\r';
const dataStream = `${`${prefix}${kept}`.repeat(repeatCount)}\n`;
const references = [...dataStream.matchAll(/\uFFFC/g)].map((match, index) => ({ index: match.index!, id: `note-${index}` }));
const bed = createParagraphLayoutTestBed('', {
documentStyle: { documentFlavor: DocumentFlavor.TRADITIONAL, pageSize: { width: 120, height: constraint === 'widowControl' ? 128 : 120 }, marginTop: 10, marginBottom: 10, marginLeft: 10, marginRight: 10 },
body: {
dataStream,
paragraphs: [...dataStream.matchAll(/\r/g)].map((match, index) => ({ startIndex: match.index!, paragraphId: `p-${index}`, paragraphStyle: constraint === 'keepNext'
? { keepNext: index % 3 === 1 ? BooleanNumber.TRUE : BooleanNumber.FALSE, keepLines: index % 3 === 2 ? BooleanNumber.TRUE : BooleanNumber.FALSE }
: { [constraint]: index % 2 === 1 ? BooleanNumber.TRUE : BooleanNumber.FALSE } })),
sectionBreaks: [{ sectionId: 'main', startIndex: dataStream.length - 1 }],
customRanges: references.map(({ index, id }) => ({ rangeId: `ref-${id}`, rangeType: CustomRangeType.FOOTNOTE, startIndex: index, endIndex: index, wholeEntity: true, properties: { noteId: id } })),
},
noteSettings: { footnote: { restart: 'eachPage', startNumber: 9 } },
notes: Object.fromEntries(references.map(({ id }) => [id, { type: 'footnote' as const, noteId: id, body: { dataStream: 'Note\r\n', paragraphs: [{ startIndex: 4, paragraphId: 'note-text' }], sectionBreaks: [{ startIndex: 5, sectionId: 'note-section' }] } }])),
});
try {
const skeleton = DocumentSkeleton.create(bed.viewModel, bed.ctx.docsConfig.localeService);
if (incremental) {
const generation = skeleton.startIncrementalLayout();
let progress = skeleton.stepIncrementalLayout(generation, 0);
for (let step = 0; step < 20_000 && !progress.complete; step++) {
progress = skeleton.stepIncrementalLayout(generation, 0);
}
expect(progress).toMatchObject({ complete: true, cancelled: false });
} else {
skeleton.calculate();
}
const pages = skeleton.getSkeletonData()!.pages;
expect(pages.flatMap(getPageGlyphs).filter((glyph) => glyph.count > 0).map((glyph) => glyph.raw).join('')).toBe(dataStream);
const referenceWidth = pages.flatMap(getPageGlyphs).find((glyph) => glyph.noteId === 'note-0')!.bBox.width;
for (const page of pages) {
const refs = getPageGlyphs(page).filter((glyph) => glyph.noteId);
expect(refs.map((glyph) => glyph.content)).toEqual(refs.map((_, index) => String(9 + index)));
for (const glyph of refs) {
if (glyph.content === '9') {
expect(glyph.bBox.width).toBeCloseTo(referenceWidth);
}
const note = page.notes?.find((fragment) => fragment.noteId === glyph.noteId && !fragment.continued);
expect(note).toBeDefined();
expect(getPageGlyphs(note!.page).find((item) => item.count === 0)?.content).toBe(glyph.content);
}
for (const section of page.sections) {
for (const column of section.columns) {
for (const line of column.lines) {
for (const divide of line.divides) {
for (let index = 1; index < divide.glyphGroup.length; index++) {
const previous = divide.glyphGroup[index - 1];
expect(divide.glyphGroup[index].left).toBeCloseTo(previous.left + previous.width);
}
}
}
}
}
}
if (constraint === 'keepNext') {
for (let index = 0; index < references.length; index += 2) {
const headingPage = pages.find((page) => getPageGlyphs(page).some((glyph) => glyph.noteId === references[index].id));
const followingPage = pages.find((page) => getPageGlyphs(page).some((glyph) => glyph.noteId === references[index + 1].id));
expect(headingPage).toBe(followingPage);
}
}
const paragraphEnds = [...dataStream.matchAll(/\r/g)].filter((_, index) => constraint === 'keepNext' ? index % 3 === 2 : index % 2 === 1).map((match) => match.index!);
for (const end of paragraphEnds) {
const counts = pages.map((page) => page.sections.flatMap((section) => section.columns.flatMap((column) => column.lines)).filter((line) => line.paragraphIndex === end).length).filter((count) => count > 0);
if (constraint !== 'widowControl') {
expect(counts).toHaveLength(1);
} else {
expect(counts.every((count) => count >= 2)).toBe(true);
}
}
} finally {
bed.viewModel.dispose();
bed.dataModel.dispose();
}
});
it('places a table-first footnote marker in its first cell without consuming an editable character', () => {
const T = DataStreamTreeTokenType;
const tableStream = `${T.TABLE_START}${T.TABLE_ROW_START}${T.TABLE_CELL_START}First\r\n${T.TABLE_CELL_END}${T.TABLE_CELL_START}Second\r\n${T.TABLE_CELL_END}${T.TABLE_ROW_END}${T.TABLE_END}`;
const dataStream = `${tableStream}After\r\n`;
const bed = createNoteTestBed(600, {
body: {
dataStream,
paragraphs: [...dataStream.matchAll(/\r/g)].map((match, index) => ({ startIndex: match.index!, paragraphId: `note-${index}` })),
sectionBreaks: [{ startIndex: dataStream.length - 1, sectionId: 'note-section' }],
tables: [{ tableId: 'note-table', startIndex: 0, endIndex: tableStream.length }],
},
tableSource: {
'note-table': {
tableId: 'note-table',
align: 0,
indent: { v: 0 },
textWrap: 0,
position: { positionH: { relativeFrom: 0 }, positionV: { relativeFrom: 0 } },
dist: { distT: 0, distB: 0, distL: 0, distR: 0 },
size: { type: 0, width: { v: 300 } },
tableRows: [{ tableCells: [{}, {}], trHeight: { val: { v: 0 }, hRule: 0 } }],
tableColumns: [{ size: { type: 0, width: { v: 150 } } }, { size: { type: 0, width: { v: 150 } } }],
},
},
});
try {
const pages = layoutNoteBody(bed.ctx, resolveNoteReferences(bed.dataModel.getSnapshot()).get(9)!, bed.sectionBreakConfig, {
width: 320,
firstPageHeight: 200,
continuationPageHeight: 200,
});
const cells = pages.flatMap((page) => [...page.skeTables.values()].flatMap((table) => table.rows.flatMap((row) => row.cells)));
expect(cells).toHaveLength(2);
const first = getPageGlyphs(cells[0]);
expect(first[0]).toMatchObject({ content: 'VIII', raw: '', count: 0 });
expect(first.slice(1).map((glyph) => glyph.raw).join('')).toBe('First\r\n');
expect(first[1].left).toBeCloseTo(first[0].left + first[0].width, 5);
expect(getPageGlyphs(cells[1]).map((glyph) => glyph.raw).join('')).toBe('Second\r\n');
expect([...pages.flatMap(getPageGlyphs), ...cells.flatMap(getPageGlyphs)].filter((glyph) => glyph.content === 'VIII')).toHaveLength(1);
} finally {
bed.viewModel.dispose();
bed.dataModel.dispose();
}
});
it('keeps the reference font separate from editable footnote text', () => {
const bed = createNoteTestBed(600, {
referenceTextStyle: { fs: 8, va: BaselineOffset.SUPERSCRIPT },
body: {
dataStream: 'Explanation\r\n',
paragraphs: [{ startIndex: 11, paragraphId: 'note-text' }],
sectionBreaks: [{ startIndex: 12, sectionId: 'note-section' }],
textRuns: [{ st: 0, ed: 11, ts: { fs: 9.5 } }],
},
});
const pages = layoutNoteBody(bed.ctx, resolveNoteReferences(bed.dataModel.getSnapshot()).get(9)!, bed.sectionBreakConfig, {
width: 320,
columnCount: 1,
firstPageHeight: 100,
continuationPageHeight: 100,
});
try {
const [marker, text] = getPageGlyphs(pages[0]);
expect(marker).toMatchObject({ count: 0, ts: { fs: 8, va: BaselineOffset.SUPERSCRIPT } });
expect(marker.fontStyle?.originFontSize).toBe(8);
expect(marker.fontStyle?.fontSize).toBeLessThan(8);
expect(text.ts?.fs).toBe(9.5);
expect(text.ts?.va).not.toBe(BaselineOffset.SUPERSCRIPT);
expect(text.fontStyle?.fontSize).toBe(9.5);
} finally {
bed.viewModel.dispose();
bed.dataModel.dispose();
}
});
it.each([1, 100])('lays out two notes in shared columns with a %i-length first note', (repeatCount) => {
const noteText = 'Explanation with several words to wrap across lines.';
const body = {
dataStream: `${noteText}\r\n`,
paragraphs: [{ paragraphId: 'p', startIndex: noteText.length }],
sectionBreaks: [{ sectionId: 's', startIndex: noteText.length + 1 }],
};
const firstText = noteText.repeat(repeatCount);
const firstBody = { dataStream: `${firstText}\r\n`, paragraphs: [{ paragraphId: 'first', startIndex: firstText.length }], sectionBreaks: [{ sectionId: 'first-section', startIndex: firstText.length + 1 }] };
const bed = createParagraphLayoutTestBed('\uFFFC\uFFFC', {
documentStyle: { documentFlavor: DocumentFlavor.TRADITIONAL },
noteSettings: { footnote: { columnCount: 2 } },
body: { customRanges: [0, 1].map((index) => ({
rangeType: CustomRangeType.FOOTNOTE,
rangeId: `ref-${index}`,
startIndex: index,
endIndex: index,
wholeEntity: true,
properties: { noteId: `note-${index}` },
})) },
notes: {
'note-0': { type: 'footnote' as const, noteId: 'note-0', body: firstBody },
'note-1': { type: 'footnote' as const, noteId: 'note-1', body: structuredClone(body) },
},
});
const skeleton = DocumentSkeleton.create(bed.viewModel, bed.ctx.docsConfig.localeService);
try {
skeleton.calculate();
const pages = skeleton.getSkeletonData()!.pages;
const notes = pages[0].notes!;
if (repeatCount > 1) {
const allNotes = pages.flatMap((page) => page.notes ?? []);
expect(allNotes.length).toBeGreaterThan(2);
expect(allNotes[allNotes.length - 1].noteId).toBe('note-1');
expect(allNotes[allNotes.length - 1].continued).toBe(false);
for (const [id, expected] of [['note-0', firstBody.dataStream], ['note-1', body.dataStream]]) {
const raw = allNotes.filter((note) => note.noteId === id).flatMap((note) =>
note.page.sections.flatMap((section) => section.columns.flatMap((column) =>
column.lines.flatMap((line) => line.divides.flatMap((divide) => divide.glyphGroup.filter((glyph) => glyph.count > 0).map((glyph) => glyph.raw)))))).join('');
expect(raw).toBe(expected);
}
return;
}
expect(notes).toHaveLength(2);
expect(notes[0].page.sections[0].columns[0].lines.length).toBeGreaterThan(0);
expect(notes[1].page.sections[0].columns[1].lines.length).toBeGreaterThan(0);
expect(notes[0].top).toBe(notes[1].top);
const column = notes[1].page.sections[0].columns[1];
const line = column.lines[0];
const divide = line.divides[0];
const glyph = divide.glyphGroup.find((item) => item.count > 0)!;
const point = Vector2.create(notes[1].left + column.left + divide.left + divide.paddingLeft + glyph.left + 1, notes[1].top + line.top + 1);
expect(skeleton.findNodeByCoord(point, PageLayoutType.VERTICAL, 0, 0)?.segmentId).toBe('note-1');
for (const note of notes) {
const glyphs = note.page.sections.flatMap((section) => section.columns.flatMap((column) =>
column.lines.flatMap((line) => line.divides.flatMap((divide) => divide.glyphGroup))));
expect(glyphs.filter((glyph) => glyph.count > 0).map((glyph) => glyph.raw).join('')).toBe(body.dataStream);
expect(note.page.st).toBe(0);
}
} finally {
bed.viewModel.dispose();
bed.dataModel.dispose();
}
});
it.each([1, 2, 3, 4])('continues rich paragraphs through %i columns without changing note character offsets', (columnCount) => {
const bed = createNoteTestBed();
const snapshot = bed.dataModel.getSnapshot();
const reference = resolveNoteReferences(snapshot).get(9)!;
const pages = layoutNoteBody(bed.ctx, reference, bed.sectionBreakConfig, {
width: 320,
columnCount,
firstPageHeight: 45,
continuationPageHeight: 100,
});
expect(pages.length).toBeGreaterThan(2);
expect(pages.every((page) => page.sections[0].columns.length === columnCount)).toBe(true);
expect(pages.every((page) => page.type === DocumentSkeletonPageType.NOTE && page.segmentId === 'note')).toBe(true);
expect(pages[0].height).toBeLessThanOrEqual(47);
expect(pages.slice(1).every((page) => page.height <= 102)).toBe(true);
const glyphs = pages.flatMap((page) => page.sections.flatMap((section) => section.columns.flatMap(
(column) => column.lines.flatMap((line) => line.divides.flatMap((divide) => divide.glyphGroup))
)));
expect(glyphs[0]).toMatchObject({ content: 'VIII', raw: '', count: 0 });
expect(glyphs.filter((glyph) => glyph.count > 0).map((glyph) => glyph.raw).join('')).toBe(snapshot.notes!.note.body.dataStream);
expect(pages[0].st).toBe(0);
for (let index = 1; index < pages.length; index++) {
expect(pages[index].st).toBe(pages[index - 1].ed + 1);
}
expect(snapshot.notes!.note.body.dataStream.startsWith('Explanation')).toBe(true);
bed.viewModel.dispose();
bed.dataModel.dispose();
});
it.each([
{ prefixCount: 0, rowCount: 1, referenceRow: 0 },
{ prefixCount: 11, rowCount: 1, referenceRow: 0 },
{ prefixCount: 14, rowCount: 1, referenceRow: 0 },
{ prefixCount: 0, rowCount: 20, referenceRow: 0 },
{ prefixCount: 0, rowCount: 20, referenceRow: 5 },
{ prefixCount: 0, rowCount: 20, referenceRow: 19 },
{ prefixCount: 0, rowCount: 20, referenceRow: 0, noteParagraphs: 30 },
{ prefixCount: 0, rowCount: 1, referenceRow: 0, cellParagraphs: 20 },
{ prefixCount: 0, rowCount: 1, referenceRow: 0, cellParagraphs: 20, referenceParagraph: 10 },
{ prefixCount: 0, rowCount: 1, referenceRow: 0, cellParagraphs: 20, referenceParagraph: 19 },
{ prefixCount: 0, rowCount: 1, referenceRow: 0, cellParagraphs: 20, columnCount: 2, referenceColumn: 1 },
{ prefixCount: 0, rowCount: 1, referenceRow: 0, cellParagraphs: 20, noteParagraphs: 30 },
{ prefixCount: 0, rowCount: 1, referenceRow: 0, cellParagraphs: 20, referenceParagraphs: [0, 10, 19] },
{ prefixCount: 0, rowCount: 1, referenceRow: 0, cellParagraphs: 20, referenceParagraphs: [0, 10, 19], restartEachPage: true },
{ prefixCount: 0, rowCount: 20, referenceRow: 0, referenceRows: [0, 4, 8, 12, 16, 19], restartEachPage: true, cantSplit: true, noteParagraphs: 1 },
{ prefixCount: 0, rowCount: 1, referenceRow: 0, cellParagraphs: 20, referenceParagraphs: [0, 10, 19], restartEachPage: true, startNumber: 9 },
{ prefixCount: 7, rowCount: 12, referenceRow: 0, referenceRows: [0, 3, 6, 9, 11], restartEachPage: true, cantSplit: true, noteParagraphs: 1, exactRowHeight: 30 },
{ prefixCount: 0, rowCount: 1, referenceRow: 0, cellParagraphs: 20, referenceParagraphs: [0, 10, 19], referenceColumns: [0, 1], columnCount: 2, restartEachPage: true, noteParagraphs: 1 },
{ prefixCount: 0, rowCount: 1, referenceRow: 0, cellParagraphs: 20, columnCount: 2, referenceColumns: [0, 1] },
].flatMap((sample, caseIndex) => [false, true].map((incremental) => ({ ...sample, incremental, caseIndex }))))('keeps table references with their notes (case $caseIndex, incremental: $incremental)', ({ prefixCount, rowCount, referenceRow, incremental, noteParagraphs = 4, cellParagraphs = 1, referenceParagraph = 0, columnCount = 1, referenceColumn = 0, referenceParagraphs = [referenceParagraph], referenceColumns = [referenceColumn], restartEachPage = false, startNumber = 1, cantSplit = false, referenceRows = [referenceRow], exactRowHeight = 0 }) => {
const T = DataStreamTreeTokenType;
const cellText = (row: number, column: number) => `${Array.from({ length: cellParagraphs }, (_, paragraph) =>
`Cell${referenceRows.includes(row) && referenceColumns.includes(column) && referenceParagraphs.includes(paragraph) ? '\uFFFC' : ''}${T.PARAGRAPH}`).join('')}${T.SECTION_BREAK}`;
const tableStream = `${T.TABLE_START}${Array.from({ length: rowCount }, (_, index) => `${T.TABLE_ROW_START}${Array.from({ length: columnCount }, (_, column) => `${T.TABLE_CELL_START}${cellText(index, column)}${T.TABLE_CELL_END}`).join('')}${T.TABLE_ROW_END}`).join('')}${T.TABLE_END}`;
const prefix = 'Intro\r'.repeat(prefixCount);
const dataStream = `${prefix}${tableStream}\r\n`;
const noteStream = `${'Explanation\r'.repeat(noteParagraphs)}\n`;
const references = [...dataStream.matchAll(/\uFFFC/g)].map((match, index) => ({ index: match.index!, id: `note-${index}` }));
const bed = createParagraphLayoutTestBed('', {
documentStyle: { documentFlavor: DocumentFlavor.TRADITIONAL, pageSize: { width: 400, height: 200 } },
body: {
dataStream,
paragraphs: [...dataStream.matchAll(/\r/g)].map((match, index) => ({ startIndex: match.index!, paragraphId: `p-${index}` })),
sectionBreaks: [{ sectionId: 'main', startIndex: dataStream.length - 1 }],
tables: [{ tableId: 'table', startIndex: prefix.length, endIndex: prefix.length + tableStream.length }],
customRanges: references.map(({ index, id }) => ({ rangeId: `ref-${id}`, rangeType: CustomRangeType.FOOTNOTE, startIndex: index, endIndex: index, wholeEntity: true, properties: { noteId: id } })),
},
tableSource: {
table: {
tableId: 'table',
align: 0,
indent: { v: 0 },
textWrap: 0,
size: { type: 0, width: { v: 300 } },
tableRows: Array.from({ length: rowCount }, () => ({ cantSplit: cantSplit ? 1 : 0, tableCells: Array.from({ length: columnCount }, () => ({ size: { type: 0, width: { v: 300 / columnCount } } })), trHeight: { val: { v: exactRowHeight }, hRule: exactRowHeight > 0 ? 2 : 0 } })),
tableColumns: Array.from({ length: columnCount }, () => ({ size: { type: 0, width: { v: 300 / columnCount } } })),
},
},
noteSettings: { footnote: restartEachPage ? { restart: 'eachPage', startNumber } : undefined },
notes: Object.fromEntries(references.map(({ id }) => [id, { type: 'footnote' as const, noteId: id, body: {
dataStream: noteStream,
paragraphs: [...noteStream.matchAll(/\r/g)].map((match, index) => ({ paragraphId: `note-${index}`, startIndex: match.index! })),
sectionBreaks: [{ sectionId: 'note-section', startIndex: noteStream.length - 1 }],
} }])),
});
const skeleton = DocumentSkeleton.create(bed.viewModel, bed.ctx.docsConfig.localeService);
try {
if (incremental) {
const generation = skeleton.startIncrementalLayout();
let progress = skeleton.stepIncrementalLayout(generation, 0);
for (let step = 0; step < 20_000 && !progress.complete; step++) {
progress = skeleton.stepIncrementalLayout(generation, 0);
}
expect(progress).toMatchObject({ complete: true, cancelled: false });
} else {
skeleton.calculate();
}
const pages = skeleton.getSkeletonData()!.pages;
if (prefixCount === 0 && rowCount === 1 && cellParagraphs === 1) {
expect(pages).toHaveLength(1);
} else {
expect(pages.length).toBeGreaterThan(1);
}
const tablePages = pages.filter((page) => page.skeTables.size > 0);
if (restartEachPage) {
const markerWidths = new Map<string, number>();
for (const page of tablePages) {
const refs = [...page.skeTables.values()].flatMap((table) => table.rows.flatMap((row) => row.cells.flatMap(getPageGlyphs))).filter((glyph) => glyph.noteId);
expect(refs.map((glyph) => glyph.content)).toEqual(refs.map((_, index) => String(index + startNumber)));
for (const glyph of refs) {
const previousWidth = markerWidths.get(glyph.content);
if (previousWidth != null) {
expect(glyph.width).toBeCloseTo(previousWidth);
}
markerWidths.set(glyph.content, glyph.width);
const note = page.notes?.find((fragment) => fragment.noteId === glyph.noteId && !fragment.continued);
expect(note).toBeDefined();
expect(getPageGlyphs(note!.page).find((item) => item.count === 0)?.content).toBe(glyph.content);
}
}
}
const rows = tablePages.flatMap((page) => [...page.skeTables.values()].flatMap((table) => table.rows));
expect(new Set(rows.map((row) => row.index)).size).toBe(rowCount);
for (let column = 0; column < columnCount; column++) {
const text = rows.flatMap((row) => getPageGlyphs(row.cells[column])).filter((glyph) => glyph.count > 0).map((glyph) => glyph.raw).join('');
expect(text).toBe(Array.from({ length: rowCount }, (_, row) => cellText(row, column)).join(''));
}
const page = tablePages.find((page) => [...page.skeTables.values()].some((table) =>
table.rows.some((row) => row.cells.some((cell) => getPageGlyphs(cell).some((glyph) => glyph.noteId === 'note-0')))))!;
expect(page.notes!.some((note) => note.noteId === 'note-0')).toBe(true);
const fragments = pages.flatMap((page) => page.notes ?? []);
expect(fragments[0].continued).toBe(false);
for (const { id } of references) {
const noteFragments = fragments.filter((fragment) => fragment.noteId === id);
expect(noteFragments[0].continued).toBe(false);
expect(noteFragments.slice(1).every((fragment) => fragment.continued)).toBe(true);
expect(noteFragments.flatMap((fragment) => getPageGlyphs(fragment.page)).filter((glyph) => glyph.count > 0).map((glyph) => glyph.raw).join('')).toBe(noteStream);
}
const table = [...page.skeTables.values()][0];
expect(page.marginTop + table.top + table.height).toBeLessThanOrEqual(page.noteDecorations![0].top + 0.01);
for (const tablePage of tablePages) {
if (tablePage.footnoteHeight) {
const bottom = Math.max(...[...tablePage.skeTables.values()].map((table) => table.top + table.height));
expect(tablePage.marginTop + bottom).toBeLessThanOrEqual(tablePage.noteDecorations![0].top + 0.01);
}
}
} finally {
bed.viewModel.dispose();
bed.dataModel.dispose();
}
});
it.each(['pageBottom', 'beneathText'] as const)('positions a short note at %s without changing its text', (position) => {
const body = {
dataStream: 'Explanation\r\n',
paragraphs: [{ paragraphId: 'np', startIndex: 11 }],
sectionBreaks: [{ sectionId: 'ns', startIndex: 12 }],
};
const bed = createNoteTestBed(600, { body });
bed.dataModel.getSnapshot().noteSettings = { footnote: { position } };
const skeleton = DocumentSkeleton.create(bed.viewModel, bed.ctx.docsConfig.localeService);
try {
skeleton.calculate();
const pages = skeleton.getSkeletonData()!.pages;
expect(pages).toHaveLength(1);
const page = pages[0];
const note = page.notes![0];
const separator = page.noteDecorations![0];
const bodyLine = page.sections[0].columns[0].lines[0];
const bodyBottom = page.marginTop + bodyLine.top + bodyLine.lineHeight;
expect(note.top).toBeCloseTo(separator.top + separator.page.height);
if (position === 'beneathText') {
expect(separator.top).toBeCloseTo(bodyBottom);
expect(note.top + note.page.height).toBeLessThan(page.pageHeight - page.marginBottom - 100);
} else {
expect(note.top + note.page.height).toBeCloseTo(page.pageHeight - page.marginBottom);
expect(separator.top).toBeGreaterThan(bodyBottom + 100);
}
expect(getPageGlyphs(note.page).filter((glyph) => glyph.count > 0).map((glyph) => glyph.raw).join('')).toBe(body.dataStream);
} finally {
bed.viewModel.dispose();
bed.dataModel.dispose();
}
});
it('reserves custom separator and continuation notice paragraphs and publishes them to the Worker consumer', () => {
const bed = createNoteTestBed(200);
const makeBody = (text: string) => ({
dataStream: `${text}\r\n`,
paragraphs: [{ paragraphId: 'decoration', startIndex: text.length, paragraphStyle: { spaceBelow: { v: 17 } } }],
sectionBreaks: [{ sectionId: 'decoration', startIndex: text.length + 1 }],
});
bed.dataModel.getSnapshot().noteSettings = { footnote: {
separator: makeBody('Notes'),
continuationSeparator: makeBody('Notes continued'),
continuationNotice: makeBody('Continued on the following page'),
} };
const skeleton = DocumentSkeleton.create(bed.viewModel, bed.ctx.docsConfig.localeService);
try {
skeleton.calculate();
const pages = skeleton.getSkeletonData()!.pages;
expect(pages.length).toBeGreaterThan(1);
for (let index = 0; index < pages.length; index++) {
const page = pages[index];
const decorations = page.noteDecorations!;
const separator = decorations[0];
expect(separator.kind).toBe(index === 0 ? 'separator' : 'continuationSeparator');
expect(getPageGlyphs(separator.page).map((glyph) => glyph.raw).join('')).toBe(index === 0 ? 'Notes\r\n' : 'Notes continued\r\n');
expect(page.notes![0].top).toBeCloseTo(separator.top + separator.page.height);
const lastLine = separator.page.sections[0].columns[0].lines.slice(-1)[0];
expect(separator.page.height).toBeCloseTo(lastLine.top + lastLine.lineHeight + 17);
const notice = decorations.find((decoration) => decoration.kind === 'continuationNotice');
expect(Boolean(notice)).toBe(index < pages.length - 1);
if (notice) {
const noteBottom = Math.max(...page.notes!.map((note) => note.top + note.page.height));
expect(notice.top).toBeCloseTo(noteBottom);
expect(notice.top + notice.page.height).toBeLessThanOrEqual(page.pageHeight - page.marginBottom + 0.01);
}
const patch = serializeDocumentSkeletonPage(page, true);
expect(() => JSON.stringify(patch)).not.toThrow();
const restored = hydrateDocumentSkeletonPage(patch, undefined, bed.dataModel.getSnapshot());
const restoredPage = restored.noteDecorations![0].page;
expect(restoredPage.sections[0].parent).toBe(restoredPage);
expect(getPageGlyphs(restoredPage).map((glyph) => glyph.raw)).toEqual(getPageGlyphs(separator.page).map((glyph) => glyph.raw));
}
} finally {
bed.viewModel.dispose();
bed.dataModel.dispose();
}
});
it.each([10, 11, 12, 13, 14, 15])('does not carry a speculative note when its reference line moves after %i paragraphs', (prefixCount) => {
const prefix = 'Intro\r'.repeat(prefixCount);
const dataStream = `${prefix}\uFFFC ${'A paragraph that wraps over several lines. '.repeat(8)}\r\n`;
const noteStream = 'A short explanation.\r\n';
const bed = createParagraphLayoutTestBed('', {
documentStyle: { documentFlavor: DocumentFlavor.TRADITIONAL, pageSize: { width: 400, height: 200 } },
body: {
dataStream,
paragraphs: [...dataStream.matchAll(/\r/g)].map((match, index) => ({ startIndex: match.index!, paragraphId: `p-${index}` })),
sectionBreaks: [{ sectionId: 'main', startIndex: dataStream.length - 1 }],
customRanges: [{ rangeId: 'ref', rangeType: CustomRangeType.FOOTNOTE, startIndex: prefix.length, endIndex: prefix.length, wholeEntity: true, properties: { noteId: 'note' } }],
},
notes: { note: { type: 'footnote' as const, noteId: 'note', body: {
dataStream: noteStream,
paragraphs: [{ paragraphId: 'note-text', startIndex: noteStream.length - 2 }],
sectionBreaks: [{ sectionId: 'note-section', startIndex: noteStream.length - 1 }],
} } },
});
const skeleton = DocumentSkeleton.create(bed.viewModel, bed.ctx.docsConfig.localeService);
try {
skeleton.calculate();
const pages = skeleton.getSkeletonData()!.pages;
const fragments = pages.flatMap((page) => page.notes ?? []);
expect(fragments).toHaveLength(1);
expect(fragments.flatMap((fragment) => getPageGlyphs(fragment.page)).filter((glyph) => glyph.count > 0).map((glyph) => glyph.raw).join('')).toBe(noteStream);
expect(pages.find((page) => getPageGlyphs(page).some((glyph) => glyph.noteId === 'note'))?.notes).toHaveLength(1);
} finally {
bed.viewModel.dispose();
bed.dataModel.dispose();
}
});
it('uses short and full-width default separator marks on first and continued pages', () => {
const bed = createNoteTestBed(200);
const skeleton = DocumentSkeleton.create(bed.viewModel, bed.ctx.docsConfig.localeService);
try {
skeleton.calculate();
const pages = skeleton.getSkeletonData()!.pages;
expect(pages.length).toBeGreaterThan(1);
for (const [index, page] of pages.entries()) {
const separator = page.noteDecorations![0];
const marker = getPageGlyphs(separator.page).find((glyph) => glyph.noteSeparator)!;
expect(marker.raw).toBe('\uFFFC');
expect(marker.count).toBe(1);
expect(marker.width).toBe(index === 0 ? 192 : page.pageWidth - page.marginLeft - page.marginRight);
}
} finally {
bed.viewModel.dispose();
bed.dataModel.dispose();
}
});
it('publishes note fragments without parent cycles and restores their local hit-test hierarchy', () => {
const bed = createNoteTestBed();
const reference = resolveNoteReferences(bed.dataModel.getSnapshot()).get(9)!;
const pages = layoutNoteBody(bed.ctx, reference, bed.sectionBreakConfig, {
width: 180,
firstPageHeight: 100,
continuationPageHeight: 100,
});
bed.curPage.notes = [{
noteId: 'note',
referenceIndex: 9,
continued: false,
left: 20,
top: 400,
page: pages[0],
}];
const patch = serializeDocumentSkeletonPage(bed.curPage, true);
expect(() => JSON.stringify(patch)).not.toThrow();
const restored = hydrateDocumentSkeletonPage(patch, undefined, bed.dataModel.getSnapshot());
const notePage = restored.notes![0].page;
const section = notePage.sections[0];
const column = section.columns[0];
const line = column.lines[0];
const divide = line.divides[0];
expect(section.parent).toBe(notePage);
expect(column.parent).toBe(section);
expect(line.parent).toBe(column);
expect(divide.glyphGroup[0].parent).toBe(divide);
expect(notePage.st).toBe(0);
expect(restored.notes![0].top).toBe(400);
bed.viewModel.dispose();
bed.dataModel.dispose();
});
});
@@ -0,0 +1,83 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { IDocumentData, IDocumentNote, IFootnoteProperties } from '@univerjs/core';
import { CustomRangeType } from '@univerjs/core';
import { describe, expect, it } from 'vitest';
import { formatNoteNumber, resolveNoteReferences } from '../note-numbering';
function createSnapshot(restart: IFootnoteProperties['restart']): Pick<IDocumentData, 'body' | 'notes' | 'noteSettings'> {
const notes: Record<string, IDocumentNote> = {};
for (let index = 0; index < 5; index++) {
const noteId = `note-${index}`;
notes[noteId] = { type: 'footnote' as const, noteId, body: { dataStream: 'Explanation\r\n' } };
}
notes['note-1'].customMark = '†';
return {
body: {
dataStream: '\uFFFC\uFFFC\uFFFC\r\n\uFFFC\uFFFC\r\n',
customRanges: [0, 1, 2, 5, 6].map((offset, index) => ({
rangeId: `reference-${index}`,
rangeType: CustomRangeType.FOOTNOTE,
startIndex: offset,
endIndex: offset,
wholeEntity: true,
properties: { noteId: `note-${index}` },
})),
sectionBreaks: [
{ sectionId: 'section-1', startIndex: 4 },
{ sectionId: 'section-2', startIndex: 8 },
],
},
notes,
noteSettings: { footnote: { restart, startNumber: 3, numberFormat: 'lowerRoman' } },
};
}
describe('footnote numbering', () => {
it('keeps custom marks out of the automatic sequence without changing persisted text', () => {
const snapshot = createSnapshot('continuous');
const original = JSON.stringify(snapshot);
const references = resolveNoteReferences(snapshot);
expect([...references.values()].map((reference) => reference.label)).toEqual(['iii', '†', 'iv', 'v', 'vi']);
expect(JSON.stringify(snapshot)).toBe(original);
});
it('restarts at section boundaries and inherits the document number format', () => {
const snapshot = createSnapshot('eachSect');
snapshot.body!.sectionBreaks![1].noteProperties = { footnote: { startNumber: 9 } };
expect([...resolveNoteReferences(snapshot).values()].map((reference) => reference.label))
.toEqual(['iii', '†', 'iv', 'ix', 'x']);
});
it('renumbers after a reference moves to a new physical page without rewriting the reference', () => {
const snapshot = createSnapshot('eachPage');
const pages = new Map([['note-0', 0], ['note-1', 1], ['note-2', 1], ['note-3', 2], ['note-4', 2]]);
expect([...resolveNoteReferences(snapshot, pages).values()].map((reference) => reference.label))
.toEqual(['iii', '†', 'iii', 'iii', 'iv']);
pages.set('note-4', 3);
const references = resolveNoteReferences(snapshot, pages);
expect(references.get(6)).toMatchObject({ noteId: 'note-4', label: 'iii' });
expect(snapshot.body!.customRanges![4].startIndex).toBe(6);
});
it('extends the symbol cycle and supports enclosed and full-width numbers', () => {
expect([1, 6, 7, 13].map((number) => formatNoteNumber(number, 'chicago'))).toEqual(['*', '¶', '**', '***']);
expect([1, 20, 21, 35, 36, 50].map((number) => formatNoteNumber(number, 'decimalEnclosedCircle')))
.toEqual(['①', '⑳', '㉑', '㉟', '㊱', '㊿']);
expect(formatNoteNumber(108, 'decimalFullWidth')).toBe('108');
});
});
@@ -17,11 +17,13 @@
import type { IDocumentSkeletonDivide, IDocumentSkeletonGlyph } from '../../../../../../basics/i-document-skeleton-cached';
import type { IParagraphConfig } from '../../../../../../basics/interfaces';
import {
AlignTypeH,
BooleanNumber,
DataStreamTreeTokenType,
DocumentFlavor,
DrawingTypeEnum,
GridType,
ObjectRelativeFromH,
ObjectRelativeFromV,
PositionedObjectLayoutType,
SpacingRule,
@@ -32,6 +34,7 @@ import {
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { GlyphType, LineType } from '../../../../../../basics/i-document-skeleton-cached';
import { setDocsCustomBlockRenderViewportProvider } from '../../../../custom-block-render-viewport';
import { DocumentSkeleton } from '../../../doc-skeleton';
import { Lang } from '../../../hyphenation/lang';
import { BreakPointType } from '../../../line-breaker/break';
import { createSkeletonCustomBlockGlyph } from '../../../model/glyph';
@@ -60,6 +63,146 @@ describe('layout-ruler', () => {
});
});
it.each([DocumentFlavor.TRADITIONAL, DocumentFlavor.MODERN].flatMap((documentFlavor) =>
[false, true].map((incremental) => ({ documentFlavor, incremental }))))('preserves table terminators without adding traditional blank lines (flavor: $documentFlavor, incremental: $incremental)', ({ documentFlavor, incremental }) => {
const T = DataStreamTreeTokenType;
const tableStream = `${T.TABLE_START}${T.TABLE_ROW_START}${T.TABLE_CELL_START}Cell\r\n${T.TABLE_CELL_END}${T.TABLE_ROW_END}${T.TABLE_END}`;
const dataStream = `${tableStream}\r\rAfter\r\n`;
const anchorIndex = tableStream.length;
const bed = createParagraphLayoutTestBed('', {
documentStyle: { documentFlavor },
body: {
dataStream,
paragraphs: [...dataStream.matchAll(/\r/g)].filter((match) => match.index !== anchorIndex).map((match, index) => ({ paragraphId: `p-${index}`, startIndex: match.index })),
sectionBreaks: [{ sectionId: 'cell', startIndex: dataStream.indexOf('\n') }, { sectionId: 'body', startIndex: dataStream.length - 1 }],
tables: [{ tableId: 'table', startIndex: 0, endIndex: anchorIndex }],
},
tableSource: { table: {
tableId: 'table',
align: 0,
indent: { v: 0 },
textWrap: 0,
size: { type: 0, width: { v: 200 } },
tableRows: [{ tableCells: [{ size: { type: 0, width: { v: 200 } } }], trHeight: { val: { v: 0 }, hRule: 0 } }],
tableColumns: [{ size: { type: 0, width: { v: 200 } } }],
} },
});
const skeleton = DocumentSkeleton.create(bed.viewModel, bed.ctx.docsConfig.localeService);
try {
if (incremental) {
const generation = skeleton.startIncrementalLayout();
let progress = skeleton.stepIncrementalLayout(generation, 0);
for (let step = 0; step < 20 && !progress.complete; step++) {
progress = skeleton.stepIncrementalLayout(generation, 0);
}
expect(progress.complete).toBe(true);
} else {
skeleton.calculate();
}
const page = skeleton.getSkeletonData()!.pages[0];
const table = page.skeTables.get('table')!;
const lines = page.sections.flatMap((section) => section.columns.flatMap((column) => column.lines));
const anchor = lines.find((line) => line.paragraphIndex === anchorIndex)!;
const emptyParagraph = lines.find((line) => line.paragraphIndex === anchorIndex + 1)!;
if (documentFlavor === DocumentFlavor.TRADITIONAL) {
expect(anchor.lineHeight).toBe(0);
} else {
expect(anchor.lineHeight).toBeGreaterThan(0);
}
expect(emptyParagraph.lineHeight).toBeGreaterThan(0);
expect(emptyParagraph.top).toBeCloseTo(table.top + table.height + anchor.lineHeight);
expect(bed.dataModel.getBody()?.dataStream).toBe(dataStream);
} finally {
bed.viewModel.dispose();
bed.dataModel.dispose();
}
});
it.each([
{ relativeFrom: ObjectRelativeFromH.MARGIN, align: AlignTypeH.CENTER, expectedLeft: 10 },
{ relativeFrom: ObjectRelativeFromH.PAGE, posOffset: 60, expectedLeft: 20 },
{ relativeFrom: ObjectRelativeFromH.COLUMN, posOffset: 20, expectedLeft: 20 },
])('positions floating tables in body coordinates for anchor $relativeFrom', ({ expectedLeft, ...positionH }) => {
const T = DataStreamTreeTokenType;
const tableStream = `${T.TABLE_START}${T.TABLE_ROW_START}${T.TABLE_CELL_START}Cell\r\n${T.TABLE_CELL_END}${T.TABLE_ROW_END}${T.TABLE_END}`;
const dataStream = `${tableStream}\rAfter\r\n`;
const bed = createParagraphLayoutTestBed('', {
documentStyle: { documentFlavor: DocumentFlavor.TRADITIONAL, marginLeft: 40, marginRight: 20 },
body: {
dataStream,
paragraphs: [...dataStream.matchAll(/\r/g)].map((match, index) => ({ paragraphId: `p-${index}`, startIndex: match.index })),
sectionBreaks: [{ sectionId: 'cell', startIndex: dataStream.indexOf('\n') }, { sectionId: 'body', startIndex: dataStream.length - 1 }],
tables: [{ tableId: 'table', startIndex: 0, endIndex: tableStream.length }],
},
tableSource: { table: {
tableId: 'table',
align: 0,
indent: { v: 0 },
textWrap: TableTextWrapType.WRAP,
dist: { distT: 0, distB: 0, distL: 0, distR: 0 },
position: { positionH, positionV: { relativeFrom: ObjectRelativeFromV.PARAGRAPH, posOffset: 10 } },
size: { type: 0, width: { v: 320 } },
tableRows: [{ tableCells: [{ size: { type: 0, width: { v: 320 } } }], trHeight: { val: { v: 0 }, hRule: 0 } }],
tableColumns: [{ size: { type: 0, width: { v: 320 } } }],
} },
});
try {
const skeleton = DocumentSkeleton.create(bed.viewModel, bed.ctx.docsConfig.localeService);
skeleton.calculate();
const tables = skeleton.getSkeletonData()!.pages.flatMap((page) => [...page.skeTables.values()]);
expect(tables).toHaveLength(1);
const table = tables[0];
expect(table.left).toBeCloseTo(expectedLeft);
expect(table.top).toBeCloseTo(10);
} finally {
bed.viewModel.dispose();
bed.dataModel.dispose();
}
});
it('keeps words intact below floating tables while retaining usable side wraps', () => {
const T = DataStreamTreeTokenType;
const tableStream = `${T.TABLE_START}${T.TABLE_ROW_START}${T.TABLE_CELL_START}Cell\r\n${T.TABLE_CELL_END}${T.TABLE_ROW_END}${T.TABLE_END}`;
const dataStream = `${tableStream}\rEn las Quintas columnas\r\n`;
const bed = createParagraphLayoutTestBed('', {
documentStyle: { documentFlavor: DocumentFlavor.TRADITIONAL },
body: {
dataStream,
paragraphs: [...dataStream.matchAll(/\r/g)].map((match, index) => ({ paragraphId: `p-${index}`, startIndex: match.index })),
sectionBreaks: [{ sectionId: 'cell', startIndex: dataStream.indexOf('\n') }, { sectionId: 'body', startIndex: dataStream.length - 1 }],
tables: [{ tableId: 'table', startIndex: 0, endIndex: tableStream.length }],
},
tableSource: { table: {
tableId: 'table',
align: 0,
indent: { v: 0 },
textWrap: TableTextWrapType.WRAP,
dist: { distT: 0, distB: 0, distL: 0, distR: 0 },
position: {
positionH: { relativeFrom: ObjectRelativeFromH.COLUMN, posOffset: 30 },
positionV: { relativeFrom: ObjectRelativeFromV.PARAGRAPH, posOffset: 0 },
},
size: { type: 0, width: { v: 300 } },
tableRows: [{ tableCells: [{ size: { type: 0, width: { v: 300 } } }], trHeight: { val: { v: 120 }, hRule: 0 } }],
tableColumns: [{ size: { type: 0, width: { v: 300 } } }],
} },
});
try {
const skeleton = DocumentSkeleton.create(bed.viewModel, bed.ctx.docsConfig.localeService);
skeleton.calculate();
const page = skeleton.getSkeletonData()!.pages[0];
const table = [...page.skeTables.values()][0];
const lines = page.sections.flatMap((section) => section.columns.flatMap((column) => column.lines));
const text = (line: typeof lines[number]) => line.divides.map((divide) => divide.glyphGroup.map((glyph) => glyph.content).join('')).join('');
expect(lines.map(text).join('')).toContain('En las Quintas columnas');
expect(lines.find((line) => text(line).includes('En'))!.top).toBeLessThan(table.top + table.height);
expect(lines.find((line) => text(line).includes('Quintas'))!.top).toBeGreaterThanOrEqual(table.top + table.height);
} finally {
bed.viewModel.dispose();
bed.dataModel.dispose();
}
});
function createGlyph(content: string, width: number): IDocumentSkeletonGlyph {
return {
content,
@@ -981,6 +1124,7 @@ describe('layout-ruler', () => {
0,
14,
page,
[page],
column,
section,
cache,
@@ -34,6 +34,9 @@ import {
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 { getDocumentCompatibilityPolicy } from '../../../../document-compatibility';
import { DocumentSkeleton } from '../../../doc-skeleton';
import { FontCache } from '../../../shaping-engine/font-cache';
import { updateInlineDrawingCoordsAndBorder } from '../../../tools';
import { lineBreaking } from '../linebreaking';
import { shaping } from '../shaping';
@@ -105,6 +108,7 @@ describe('linebreaking', () => {
});
});
afterEach(() => {
vi.restoreAllMocks();
setDocsCustomBlockRenderViewportProvider(null);
});
@@ -355,6 +359,91 @@ describe('linebreaking', () => {
expect(paragraphLines(result[0], secondParagraph.endIndex).length).toBeGreaterThan(0);
});
it('keeps the first cell paragraph height when its leading page break is redundant', () => {
const heights: number[] = [];
for (const breakText of ['', '\f']) {
const T = DataStreamTreeTokenType;
const cellText = `${breakText}\rEquation\r\r\n`;
const tableStream = `${T.TABLE_START}${T.TABLE_ROW_START}${T.TABLE_CELL_START}${cellText}${T.TABLE_CELL_END}${T.TABLE_ROW_END}${T.TABLE_END}`;
const dataStream = `${tableStream}\r\n`;
const bed = createParagraphLayoutTestBed('', {
documentStyle: { documentFlavor: DocumentFlavor.TRADITIONAL },
body: {
dataStream,
paragraphs: [...cellText.matchAll(/\r/g)].map((match, index) => ({ paragraphId: `p-${index}`, startIndex: match.index + 3 })),
sectionBreaks: [{ sectionId: 'cell', startIndex: cellText.length + 2 }, { sectionId: 'body', startIndex: dataStream.length - 1 }],
tables: [{ tableId: 'table', startIndex: 0, endIndex: tableStream.length }],
},
tableSource: { table: {
tableId: 'table',
align: 0,
indent: { v: 0 },
textWrap: 0,
size: { type: 0, width: { v: 200 } },
tableRows: [{ tableCells: [{ size: { type: 0, width: { v: 200 } } }], trHeight: { val: { v: 0 }, hRule: 0 } }],
tableColumns: [{ size: { type: 0, width: { v: 200 } } }],
} },
});
try {
const skeleton = DocumentSkeleton.create(bed.viewModel, bed.ctx.docsConfig.localeService);
skeleton.calculate();
const pages = skeleton.getSkeletonData()!.pages;
expect(pages).toHaveLength(1);
const table = pages[0].skeTables.get('table')!;
const lines = table.rows[0].cells[0].sections[0].columns[0].lines;
expect(lines[0].lineHeight).toBeGreaterThan(0);
expect(lines.flatMap((line) => line.divides).flatMap((divide) => divide.glyphGroup).map((glyph) => glyph.raw).join('')).toBe(cellText);
heights.push(table.height);
} finally {
bed.viewModel.dispose();
bed.dataModel.dispose();
}
}
expect(heights[1]).toBeCloseTo(heights[0]);
});
it.each([undefined, BooleanNumber.FALSE, BooleanNumber.TRUE])(
'keeps a trailing paragraph mark on the manual break line unless split compatibility is enabled (%s)',
(splitPageBreakAndParagraphMark) => {
const testBed = createSectionLayoutTestBed(['Before\f', '', 'Chapter'], {
documentStyle: { documentFlavor: DocumentFlavor.TRADITIONAL, splitPageBreakAndParagraphMark },
});
const pages = [testBed.curPage];
for (const paragraph of testBed.sectionNode.children) {
const result = lineBreaking(
testBed.ctx,
testBed.viewModel,
shaping(testBed.ctx, paragraph.content!, testBed.viewModel, paragraph, testBed.sectionBreakConfig),
pages[pages.length - 1],
paragraph,
testBed.sectionBreakConfig,
null
);
pages.push(...result.slice(1));
}
const pageGlyphs = pages.map((page) => page.sections.flatMap((section) =>
section.columns.flatMap((column) => column.lines.flatMap((line) =>
line.divides.flatMap((divide) => divide.glyphGroup)
))
));
expect(pages).toHaveLength(2);
expect(pageGlyphs.flat().map((glyph) => glyph.raw).join('')).toBe(testBed.dataModel.getBody()!.dataStream);
expect(pageGlyphs[0].map((glyph) => glyph.raw).join('')).toBe(
splitPageBreakAndParagraphMark === BooleanNumber.TRUE ? 'Before\f' : 'Before\f\r'
);
const nextPageLines = pages[1].sections[0].columns[0].lines;
// The separately authored empty paragraph remains, even when the break's own terminator stays behind.
expect(nextPageLines.filter((line) => line.divides.flatMap((divide) => divide.glyphGroup)
.map((glyph) => glyph.raw)
.join('') === '\r')).toHaveLength(
splitPageBreakAndParagraphMark === BooleanNumber.TRUE ? 2 : 1
);
for (const glyph of pageGlyphs.flat()) {
expect(glyph.parent?.glyphGroup).toContain(glyph);
}
}
);
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 },
@@ -567,6 +656,48 @@ describe('linebreaking', () => {
)).toBe(true);
});
it.each([0, 19])('an obsolete rendered page marker at offset %s does not change wrapping or disable widow control', (offset) => {
vi.spyOn(FontCache, 'getNormalLineHeight').mockReturnValue(14.8);
const text = 'One two three four five six seven eight nine ten eleven twelve';
const layout = (marker: boolean) => {
const content = marker ? `${text.slice(0, offset)}\f${text.slice(offset)}` : text;
const bed = createParagraphLayoutTestBed(content, {
documentStyle: {
documentFlavor: DocumentFlavor.TRADITIONAL,
pageSize: { width: 120, height: 85 },
marginTop: 20,
marginBottom: 20,
marginLeft: 20,
marginRight: 20,
},
body: { renderedPageBreaks: marker ? [offset] : [] },
});
bed.sectionBreakConfig.documentCompatibilityPolicy = getDocumentCompatibilityPolicy(DocumentFlavor.TRADITIONAL);
bed.curPage.pageNumber = 5;
const pages = 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 lines = pages.map((page) => paragraphLines(page, bed.paragraphNode.endIndex));
expect(lines.flatMap((pageLines) => pageLines.flatMap((line) => line.divides.flatMap((divide) => divide.glyphGroup)))
.map((glyph) => glyph.raw).join('')).toBe(bed.dataModel.getBody()!.dataStream);
return lines.map((pageLines) => pageLines.map((line) => ({
top: line.top,
height: line.lineHeight,
text: line.divides.flatMap((divide) => divide.glyphGroup).map((glyph) => glyph.raw).join('').replace(/\f/g, ''),
})));
};
const expected = layout(false);
expect(expected[0][0].height).toBeCloseTo(14.8);
expect(expected.map((lines) => lines.length).filter(Boolean)).not.toContain(1);
expect(layout(true)).toEqual(expected);
});
it('preserves a rendered page break before a fitting inline drawing', () => {
const beforeBreak = 'Intro';
const content = `${beforeBreak}${DataStreamTreeTokenType.PAGE_BREAK}${DataStreamTreeTokenType.CUSTOM_BLOCK}`;
@@ -1193,6 +1324,42 @@ describe('linebreaking', () => {
expect(testBed.ctx.paginationMetrics?.retryCount).toBeLessThanOrEqual(contents.length);
});
it.each([2, 3])('keeps a short %s-line paragraph together instead of leaving a single widow or orphan', (lineCount) => {
const layout = (widowControl: BooleanNumber) => {
const contents = [...Array.from({ length: 4 - lineCount }, () => 'Prefix'), new Array(lineCount).fill('abcdefg').join(' ')];
let offset = 0;
const paragraphs = contents.map((content) => {
offset += content.length;
return { startIndex: offset++, paragraphStyle: { widowControl } };
});
const bed = createSectionLayoutTestBed(contents, {
documentStyle: {
documentFlavor: DocumentFlavor.TRADITIONAL,
pageSize: { width: 120, height: 85 },
},
body: { paragraphs },
});
let pages = [bed.curPage];
for (const paragraph of bed.sectionNode.children) {
const next = lineBreaking(bed.ctx, bed.viewModel, shaping(bed.ctx, paragraph.content!, bed.viewModel, paragraph, bed.sectionBreakConfig), pages[pages.length - 1], paragraph, bed.sectionBreakConfig, null);
pages = [...pages.slice(0, -1), ...next];
}
const targetIndex = paragraphs[paragraphs.length - 1].startIndex;
const counts = pages.map((page) => paragraphLines(page, targetIndex).length);
const text = pages.flatMap((page) => page.sections)
.flatMap((section) => section.columns)
.flatMap((column) => column.lines)
.flatMap((line) => line.divides)
.flatMap((divide) => divide.glyphGroup)
.map((glyph) => glyph.raw)
.join('');
expect(text).toBe(bed.dataModel.getBody()!.dataStream);
return counts;
};
expect(layout(BooleanNumber.FALSE)).toEqual([lineCount - 1, 1]);
expect(layout(BooleanNumber.TRUE)).toEqual([0, lineCount]);
});
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, {
@@ -44,7 +44,7 @@ import {
TabStopAlignment,
WrapStrategy,
} from '@univerjs/core';
import { GlyphType, LineType } from '../../../../../basics/i-document-skeleton-cached';
import { DocumentSkeletonPageType, 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';
@@ -56,6 +56,7 @@ import {
createAndUpdateBlockAnchor,
createSkeletonLine,
setLineMarginBottom,
TRADITIONAL_TABLE_WRAP_MIN_WIDTH,
updateDivideInfo,
} from '../../model/line';
import { createSkeletonPage } from '../../model/page';
@@ -188,7 +189,8 @@ function isGlyphGroupEndWithWhiteSpaces(glyphGroup: IDocumentSkeletonGlyph[]) {
}
if (isInWhiteSpace &&
g.content !== DataStreamTreeTokenType.SPACE && g.content !== DataStreamTreeTokenType.PARAGRAPH && g.streamType !== DataStreamTreeTokenType.SECTION_BREAK) {
g.content !== DataStreamTreeTokenType.SPACE && g.content !== DataStreamTreeTokenType.PARAGRAPH &&
g.streamType !== DataStreamTreeTokenType.SECTION_BREAK && g.streamType !== DataStreamTreeTokenType.PAGE_BREAK) {
return false;
}
}
@@ -291,6 +293,7 @@ function _divideOperator(
) {
const lastPage = getLastPage(pages);
const divideInfo = getLastNotFullDivideInfo(lastPage); // Get the first divide in the latest line that is not full.
ctx.footnoteLayout?.updateReferenceGlyphs(lastPage, glyphGroup, sectionBreakConfig, paragraphConfig);
if (divideInfo) {
const { divide, isLast } = divideInfo;
_adjustExplicitTabStop(divide, glyphGroup, paragraphConfig);
@@ -364,6 +367,20 @@ function _divideOperator(
defaultSpanMetrics
);
} else if (divide?.glyphGroup.length === 0) {
const line = divide.parent!;
const column = line.parent!;
const section = column.parent!;
const wordWidth = __getGlyphGroupWidth(glyphGroup);
if (
isTraditionalDocumentCompatibility(paragraphConfig.documentCompatibilityPolicy!) &&
wordWidth < column.width &&
calculateLineTopByDrawings(line.lineHeight, line.top, lastPage, null, null, column.left, column.width, section.top, wordWidth - 0.001) > line.top
) {
// Preserve the shaped word when the obstacle, rather than the
// column, is too narrow. Normal long-word breaking still applies.
_lineOperator(ctx, glyphGroup, pages, sectionBreakConfig, paragraphConfig, isParagraphFirstShapedText, breakPointType, defaultSpanMetrics);
return;
}
const sliceGlyphGroup: IDocumentSkeletonGlyph[] = [];
while (glyphGroup.length) {
@@ -457,8 +474,33 @@ function _divideOperator(
} else {
// w does not exceed divide width, add it to divide
const currentLine = divide.parent;
if (currentLine?.parent?.parent && ctx.footnoteLayout && glyphGroup.some((glyph) => glyph.noteId)) {
const bodyBottom = currentLine.parent.parent.top + currentLine.top + currentLine.lineHeight;
const bodyLimit = ctx.footnoteLayout.getBodyLimit(lastPage, pages, sectionBreakConfig, bodyBottom, glyphGroup);
if (bodyBottom > bodyLimit + LINE_LAYOUT_OVERFLOW_TOLERANCE) {
const column = currentLine.parent;
const cachedGlyphs = __getGlyphGroupByLine(currentLine);
column.lines.pop();
const previousLine = column.lines[column.lines.length - 1];
const previousBottom = column.parent!.top + (previousLine == null ? 0 : previousLine.top + previousLine.lineHeight);
ctx.footnoteLayout.getBodyLimit(lastPage, pages, sectionBreakConfig, previousBottom);
_pageOperator(ctx, [...cachedGlyphs, ...glyphGroup], pages, sectionBreakConfig, paragraphConfig, currentLine.paragraphStart, breakPointType, defaultSpanMetrics);
return;
}
}
const maxBox = __maxFontBoundingBoxByGlyphGroup(glyphGroup);
if (currentLine?.parent && __isNullLine(currentLine) && __hasFlowGlyph(glyphGroup)) {
const cachedGlyphs = __getGlyphGroupByLine(currentLine);
if (cachedGlyphs.length > 0 && cachedGlyphs.every((glyph) => glyph.streamType === DataStreamTreeTokenType.PAGE_BREAK)) {
// A skipped rendered-page hint has no font metrics. Let the first real text
// establish its line, including normal font leading and available page height.
currentLine.parent.lines.pop();
_lineOperator(ctx, [...cachedGlyphs, ...glyphGroup], pages, sectionBreakConfig, paragraphConfig, currentLine.paragraphStart, breakPointType, defaultSpanMetrics);
return;
}
}
if (
currentLine &&
__isPositionedCustomBlockOnlyLine(__getGlyphGroupByLine(currentLine), paragraphConfig.paragraphNonInlineSkeDrawings) &&
@@ -623,51 +665,17 @@ function _adjustExplicitTabStop(
tabGlyph.tabLeader = tabStop.leader;
}
function _lineOperator(
function _getParagraphLineMetrics(
ctx: ILayoutContext,
glyphGroup: IDocumentSkeletonGlyph[],
pages: IDocumentSkeletonPage[],
lastPage: IDocumentSkeletonPage,
column: IDocumentSkeletonColumn,
sectionBreakConfig: ISectionBreakConfig,
paragraphConfig: IParagraphConfig,
isParagraphFirstShapedText: boolean,
breakPointType: BreakPointType = BreakPointType.Normal,
defaultSpanMetrics?: IDefaultSpanMetrics
) {
let lastPage = getLastPage(pages);
let columnInfo = getLastNotFullColumnInfo(lastPage);
if (!columnInfo || !columnInfo.column) {
const lastSection = getLastSection(lastPage);
const lastColumnIndex = lastSection.columns.length - 1;
const lastColumn = lastSection.columns[lastColumnIndex];
if (lastColumn && isBlankColumn(lastColumn)) {
setColumnFullState(lastColumn, false);
columnInfo = {
column: lastColumn,
index: lastColumnIndex,
isLast: true,
};
}
}
if (!columnInfo || !columnInfo.column) {
// If the column does not exist, use a fallback strategy and add a new page.
_pageOperator(ctx, glyphGroup, pages, sectionBreakConfig, paragraphConfig, true, breakPointType);
lastPage = getLastPage(pages);
columnInfo = getLastNotFullColumnInfo(lastPage);
}
// Todo: columnInfo does not exist when demo4 is imported, return first
if (!columnInfo) return;
const column = columnInfo!.column;
// If the page width < marginLeft + marginRight, will trigger infinity loop, so return it first.
// The best solution is to do data checks and data repairs, and pass the correct data to the render layer.
if (column.width <= 0) {
console.error('The column width is less than 0, need to adjust page width to make it great than 0');
return;
}
const preLine = getLastLineByColumn(column);
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);
@@ -677,12 +685,13 @@ function _lineOperator(
paragraphStyle: originParagraphStyle = {},
paragraphNonInlineSkeDrawings,
skeTablesInParagraph,
skeHeaders,
skeFooters,
pDrawingAnchor,
paragraphIndex,
} = paragraphConfig;
const isZeroWidthNonFlowFloatingAnchorLine = __isZeroWidthNonFlowFloatingAnchorLine(glyphGroup, paragraphNonInlineSkeDrawings);
const isSyntheticTableAnchorLine = isTraditionalDocumentCompatibility(paragraphConfig.documentCompatibilityPolicy!) &&
skeTablesInParagraph?.some(({ table }) => table.tableSource.textWrap === TableTextWrapType.NONE) === true &&
ctx.viewModel.getSelfOrHeaderFooterViewModel(lastPage.segmentId).getParagraph(paragraphIndex) == null &&
glyphGroup.every((glyph) => glyph.streamType === DataStreamTreeTokenType.PARAGRAPH || glyph.streamType === DataStreamTreeTokenType.SECTION_BREAK);
const { namedStyleType } = originParagraphStyle;
const namedStyle = namedStyleType !== undefined ? NAMED_STYLE_SPACE_MAP[namedStyleType] : null;
const paragraphStyle = {
@@ -692,13 +701,8 @@ function _lineOperator(
};
const {
// direction,
spaceAbove,
spaceBelow,
indentFirstLine,
hanging,
indentStart,
indentEnd,
} = paragraphStyle;
const {
@@ -733,9 +737,6 @@ function _lineOperator(
return glyph.streamType === DataStreamTreeTokenType.PARAGRAPH || glyph.raw === DataStreamTreeTokenType.PARAGRAPH;
});
const glyphGroupCustomBlockIds = new Set(glyphGroup
.filter((glyph) => glyph.streamType === DataStreamTreeTokenType.CUSTOM_BLOCK && glyph.drawingId != null)
.map((glyph) => glyph.drawingId!));
let { paddingTop, paddingBottom, contentHeight, lineSpacingApply } = getLineHeightMetrics(
glyphLineHeight,
paragraphLineGapDefault,
@@ -802,7 +803,9 @@ function _lineOperator(
spaceBelowApply = 0;
}
if (isZeroWidthNonFlowFloatingAnchorLine) {
// A table's structural terminator has no Word paragraph. Keep its glyph and
// character position without adding a blank line after the table.
if (isZeroWidthNonFlowFloatingAnchorLine || isSyntheticTableAnchorLine) {
paddingTop = 0;
paddingBottom = 0;
contentHeight = 0;
@@ -811,26 +814,38 @@ function _lineOperator(
spaceBelowApply = 0;
}
const lineHeight = marginTop + paddingTop + contentHeight + paddingBottom;
const { charSpace, defaultTabStop } = getCharSpaceConfig(sectionBreakConfig, paragraphConfig);
const charSpaceApply = getCharSpaceApply(charSpace, defaultTabStop, gridType, snapToGrid);
const paragraphAnchorLeft = __getParagraphAnchorLeft(sectionBreakConfig, paragraphConfig, indentStart);
let section = column.parent;
if (!section) {
// Fallback, point to the last section of the current page
section = getLastSection(lastPage);
}
const preLineHeight = preLine?.lineHeight || 0;
const preTop = preLine?.top || 0;
const lineTop = preLineHeight + preTop;
const { pageWidth, headerId, footerId, segmentId } = lastPage;
const headerPage = skeHeaders?.get(headerId)?.get(pageWidth);
const footerPage = skeFooters?.get(footerId)?.get(pageWidth);
let needOpenNewPageByTableLayout = false;
return {
paragraphStyle,
gridType,
snapToGrid,
hasInlineCustomBlock,
positionedCustomBlockOnly,
paddingTop,
paddingBottom,
contentHeight,
marginTop,
spaceBelowApply,
};
}
function _positionLineDrawings(
ctx: ILayoutContext,
glyphGroup: IDocumentSkeletonGlyph[],
column: IDocumentSkeletonColumn,
lastPage: IDocumentSkeletonPage,
paragraphConfig: IParagraphConfig,
lineTop: number,
lineHeight: number,
paragraphAnchorLeft: number,
hasInlineCustomBlock: boolean,
isParagraphFirstShapedText: boolean
) {
const preLine = getLastLineByColumn(column);
const { segmentId } = lastPage;
const { paragraphNonInlineSkeDrawings, pDrawingAnchor, paragraphIndex } = paragraphConfig;
const glyphGroupCustomBlockIds = new Set(glyphGroup
.filter((glyph) => glyph.streamType === DataStreamTreeTokenType.CUSTOM_BLOCK && glyph.drawingId != null)
.map((glyph) => glyph.drawingId!));
// Handle float object relative to line.
// FIXME: @jocs, it will not update the last line's drawings.
if (preLine) {
@@ -872,8 +887,153 @@ function _lineOperator(
__updateAndPositionDrawings(ctx, lineTop, lineHeight, column, targetDrawings, paragraphConfig.paragraphIndex, isParagraphFirstShapedText, pDrawingAnchor?.get(paragraphIndex)?.top, paragraphAnchorLeft, false, deferredTopBottomAnchorDrawings.length > 0);
}
return { deferredInlineGroupAnchorDrawings, deferredTopBottomAnchorDrawings };
}
function _getOrCreateLineColumn(
ctx: ILayoutContext,
glyphGroup: IDocumentSkeletonGlyph[],
pages: IDocumentSkeletonPage[],
sectionBreakConfig: ISectionBreakConfig,
paragraphConfig: IParagraphConfig,
breakPointType: BreakPointType
) {
let lastPage = getLastPage(pages);
let columnInfo = getLastNotFullColumnInfo(lastPage);
if (!columnInfo || !columnInfo.column) {
const lastSection = getLastSection(lastPage);
const lastColumnIndex = lastSection.columns.length - 1;
const lastColumn = lastSection.columns[lastColumnIndex];
if (lastColumn && isBlankColumn(lastColumn)) {
setColumnFullState(lastColumn, false);
columnInfo = {
column: lastColumn,
index: lastColumnIndex,
isLast: true,
};
}
}
if (!columnInfo || !columnInfo.column) {
// If the column does not exist, use a fallback strategy and add a new page.
_pageOperator(ctx, glyphGroup, pages, sectionBreakConfig, paragraphConfig, true, breakPointType);
lastPage = getLastPage(pages);
columnInfo = getLastNotFullColumnInfo(lastPage);
}
return columnInfo;
}
function _clearOverflowedParagraphDrawings(
ctx: ILayoutContext,
lastPage: IDocumentSkeletonPage,
paragraphNonInlineSkeDrawings: IParagraphConfig['paragraphNonInlineSkeDrawings'],
isParagraphFirstShapedText: boolean
): void {
const { segmentId } = lastPage;
if (isParagraphFirstShapedText && paragraphNonInlineSkeDrawings && paragraphNonInlineSkeDrawings.size > 0) {
for (const drawing of paragraphNonInlineSkeDrawings.values()) {
if (lastPage.skeDrawings.has(drawing.drawingId)) {
lastPage.skeDrawings.delete(drawing.drawingId);
}
if (ctx.floatObjectsCache.has(drawing.drawingId)) {
ctx.floatObjectsCache.delete(drawing.drawingId);
ctx.isDirty = false;
ctx.layoutStartPointer[segmentId] = null;
}
}
}
}
function _lineOperator(
ctx: ILayoutContext,
glyphGroup: IDocumentSkeletonGlyph[],
pages: IDocumentSkeletonPage[],
sectionBreakConfig: ISectionBreakConfig,
paragraphConfig: IParagraphConfig,
isParagraphFirstShapedText: boolean,
breakPointType: BreakPointType = BreakPointType.Normal,
defaultSpanMetrics?: IDefaultSpanMetrics
) {
let lastPage = getLastPage(pages);
ctx.footnoteLayout?.updateReferenceGlyphs(lastPage, glyphGroup, sectionBreakConfig, paragraphConfig);
const columnInfo = _getOrCreateLineColumn(ctx, glyphGroup, pages, sectionBreakConfig, paragraphConfig, breakPointType);
lastPage = getLastPage(pages);
// Todo: columnInfo does not exist when demo4 is imported, return first
if (!columnInfo) {
return;
}
const column = columnInfo.column;
// If the page width < marginLeft + marginRight, will trigger infinity loop, so return it first.
// The best solution is to do data checks and data repairs, and pass the correct data to the render layer.
if (column.width <= 0) {
console.error('The column width is less than 0, need to adjust page width to make it great than 0');
return;
}
const preLine = getLastLineByColumn(column);
const {
paragraphNonInlineSkeDrawings,
skeTablesInParagraph,
skeHeaders,
skeFooters,
pDrawingAnchor,
paragraphIndex,
} = paragraphConfig;
const {
paragraphStyle,
gridType,
snapToGrid,
hasInlineCustomBlock,
positionedCustomBlockOnly,
paddingTop,
paddingBottom,
contentHeight,
marginTop,
spaceBelowApply,
} = _getParagraphLineMetrics(ctx, glyphGroup, lastPage, column, sectionBreakConfig, paragraphConfig, isParagraphFirstShapedText, defaultSpanMetrics);
const { indentFirstLine, hanging, indentStart, indentEnd } = paragraphStyle;
const lineHeight = marginTop + paddingTop + contentHeight + paddingBottom;
const { charSpace, defaultTabStop } = getCharSpaceConfig(sectionBreakConfig, paragraphConfig);
const charSpaceApply = getCharSpaceApply(charSpace, defaultTabStop, gridType, snapToGrid);
const paragraphAnchorLeft = __getParagraphAnchorLeft(sectionBreakConfig, paragraphConfig, indentStart);
let section = column.parent;
if (!section) {
// Fallback, point to the last section of the current page
section = getLastSection(lastPage);
}
const preLineHeight = preLine?.lineHeight || 0;
const initialFootnoteTop = lastPage.type === DocumentSkeletonPageType.NOTE && lastPage.pageNumber === 1 && columnInfo.index === ctx.footnoteFirstColumn?.index
? ctx.footnoteFirstColumn.top
: 0;
const preTop = preLine?.top ?? initialFootnoteTop;
const lineTop = preLineHeight + preTop;
const { pageWidth, headerId, footerId } = lastPage;
const headerPage = skeHeaders?.get(headerId)?.get(pageWidth);
const footerPage = skeFooters?.get(footerId)?.get(pageWidth);
let needOpenNewPageByTableLayout = false;
const { deferredInlineGroupAnchorDrawings, deferredTopBottomAnchorDrawings } = _positionLineDrawings(
ctx,
glyphGroup,
column,
lastPage,
paragraphConfig,
lineTop,
lineHeight,
paragraphAnchorLeft,
hasInlineCustomBlock,
isParagraphFirstShapedText
);
if (skeTablesInParagraph != null && skeTablesInParagraph.length > 0) {
needOpenNewPageByTableLayout = _updateAndPositionTable(ctx, lineTop, lineHeight, lastPage, column, section, skeTablesInParagraph, paragraphConfig.paragraphIndex, sectionBreakConfig, pDrawingAnchor?.get(paragraphIndex)?.top);
needOpenNewPageByTableLayout = _updateAndPositionTable(ctx, lineTop, lineHeight, lastPage, pages, column, section, skeTablesInParagraph, paragraphConfig.paragraphIndex, sectionBreakConfig, pDrawingAnchor?.get(paragraphIndex)?.top);
}
const hasSameParagraphTopBottomDrawingWithInline = hasInlineCustomBlock &&
@@ -891,7 +1051,10 @@ function _lineOperator(
footerPage,
column.left,
column.width,
section.top
section.top,
isTraditionalDocumentCompatibility(paragraphConfig.documentCompatibilityPolicy!)
? Math.max(TRADITIONAL_TABLE_WRAP_MIN_WIDTH, Math.min(__getGlyphGroupWidth(glyphGroup) - 0.001, column.width - 1))
: 0
); // WRAP_TOP_AND_BOTTOM drawing and WRAP NONE table will change the starting top of the line
const previousTopBottomCustomBlockFlowBottom = deferredTopBottomAnchorDrawings.length > 0
? paragraphConfig.topBottomCustomBlockFlowBottom
@@ -903,12 +1066,20 @@ function _lineOperator(
// 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 footnoteBodyLimit = ctx.footnoteLayout?.getBodyLimit(
lastPage,
pages,
sectionBreakConfig,
section.top + newLineTop + lineHeight,
glyphGroup
);
const availableSectionHeight = footnoteBodyLimit == null ? section.height : Math.min(section.height, footnoteBodyLimit - section.top);
const lineOverflowsSection = !clipsSameParagraphInlineDrawing &&
lineHeight + newLineTop - section.height > LINE_LAYOUT_OVERFLOW_TOLERANCE;
lineHeight + newLineTop - availableSectionHeight > LINE_LAYOUT_OVERFLOW_TOLERANCE;
if (
(lineOverflowsSection &&
(column.lines.length > 0 || section.top > 0) &&
(column.lines.length > 0 || initialFootnoteTop > 0 || section.top > 0 || (lastPage.footnoteHeight ?? 0) > 0 || footnoteBodyLimit === -1) &&
lastPage.sections.length > 0) ||
needOpenNewPageByTableLayout
) {
@@ -926,19 +1097,7 @@ function _lineOperator(
defaultSpanMetrics
);
if (isParagraphFirstShapedText && paragraphNonInlineSkeDrawings && paragraphNonInlineSkeDrawings.size > 0) {
for (const drawing of paragraphNonInlineSkeDrawings.values()) {
if (lastPage.skeDrawings.has(drawing.drawingId)) {
lastPage.skeDrawings.delete(drawing.drawingId);
}
if (ctx.floatObjectsCache.has(drawing.drawingId)) {
ctx.floatObjectsCache.delete(drawing.drawingId);
ctx.isDirty = false;
ctx.layoutStartPointer[segmentId] = null;
}
}
}
_clearOverflowedParagraphDrawings(ctx, lastPage, paragraphNonInlineSkeDrawings, isParagraphFirstShapedText);
return;
}
@@ -1139,7 +1298,10 @@ function __getWrapTablePosition(
const { tableSource, width, height } = table;
const { positionH, positionV } = tableSource.position;
const left = getPositionHorizon(positionH, column, page, width, isPageBreak) ?? 0;
const horizontalPosition = getPositionHorizon(positionH, column, page, width, isPageBreak);
// Tables render from the body origin; page/margin anchors return page coordinates.
const pageRelative = positionH.relativeFrom === ObjectRelativeFromH.PAGE || positionH.relativeFrom === ObjectRelativeFromH.MARGIN;
const left = horizontalPosition == null ? 0 : horizontalPosition - (pageRelative ? page.marginLeft : 0);
const top = getPositionVertical(
positionV,
page,
@@ -1196,6 +1358,7 @@ function _updateAndPositionTable(
lineTop: number,
lineHeight: number,
page: IDocumentSkeletonPage,
pages: IDocumentSkeletonPage[],
column: IDocumentSkeletonColumn,
section: IDocumentSkeletonSection,
skeTablesInParagraph: IParagraphTableCache[],
@@ -1244,8 +1407,22 @@ function _updateAndPositionTable(
}
const { top, left, height } = table;
const localTop = top - section.top;
// A short table and its note must move together. Waiting until the trailing
// paragraph overflows leaves the table on the old page without its note.
// Tables that already exceed the remaining body space still use row pagination below.
if (ctx.footnoteLayout && localTop + height <= section.height && (column.lines.length > 0 || firstUnPositionedTable.isSlideTable) &&
tableSource.textWrap === TableTextWrapType.NONE) {
const bodyLimit = ctx.footnoteLayout.getTableBodyLimit(page, pages, sectionBreakConfig, table);
if (top + height > bodyLimit + LINE_LAYOUT_OVERFLOW_TOLERANCE) {
const previousLine = column.lines[column.lines.length - 1];
const bodyBottom = previousLine ? section.top + previousLine.top + previousLine.lineHeight : section.top;
ctx.footnoteLayout.getBodyLimit(page, pages, sectionBreakConfig, bodyBottom);
return true;
}
}
if (
(localTop + height > section.height || table.hasPageBreak === true) &&
firstUnPositionedTable.isSlideTable === false
@@ -1282,6 +1459,7 @@ function _updateAndPositionTable(
page.skeTables.set(firstTable.tableId, firstTable);
firstTable.parent = page;
ctx.footnoteLayout?.getTableBodyLimit(page, pages, sectionBreakConfig, firstTable);
skeTablesInParagraph.push({
table: firstTable,
tableId: firstTable.tableId,
@@ -1306,6 +1484,7 @@ function _updateAndPositionTable(
} else {
page.skeTables.set(tableId, table);
table.parent = page;
ctx.footnoteLayout?.getTableBodyLimit(page, pages, sectionBreakConfig, table);
firstUnPositionedTable.hasPositioned = true;
const isLastTable = firstUnPositionedTable === skeTablesInParagraph[skeTablesInParagraph.length - 1];
@@ -52,6 +52,7 @@ import {
} from '@univerjs/core';
import { BreakType, GlyphType } from '../../../../../basics/i-document-skeleton-cached';
import { getDocumentCompatibilityPolicy, isTraditionalDocumentCompatibility } from '../../../document-compatibility';
import { BreakPointType } from '../../line-breaker/break';
import { createSkeletonPage } from '../../model/page';
import { setColumnFullState } from '../../model/section';
import { getLastNotFullColumnInfo, getLastSection, hasSameParagraphBorderSet, isBlankColumn } from '../../tools';
@@ -537,6 +538,8 @@ function _hasOnlyExplicitPageBoundaryMarkers(page: IDocumentSkeletonPage): boole
divide.glyphGroup.every(({ raw, streamType }) =>
raw === DataStreamTreeTokenType.PARAGRAPH ||
streamType === DataStreamTreeTokenType.PARAGRAPH ||
raw === DataStreamTreeTokenType.SECTION_BREAK ||
streamType === DataStreamTreeTokenType.SECTION_BREAK ||
raw === DataStreamTreeTokenType.PAGE_BREAK ||
streamType === DataStreamTreeTokenType.PAGE_BREAK
)
@@ -570,7 +573,8 @@ function _reindexColumnLines(column: IDocumentSkeletonColumn): void {
function _prependLines(
sourceColumn: IDocumentSkeletonColumn,
targetColumn: IDocumentSkeletonColumn,
lines: IParagraphLineRef['line'][]
lines: IParagraphLineRef['line'][],
validateMove?: () => boolean
): number {
if (lines.length === 0 || lines.some((line) => !sourceColumn.lines.includes(line))) {
return 0;
@@ -581,6 +585,10 @@ function _prependLines(
return 0;
}
const sourceLines = sourceColumn.lines.slice();
const targetLines = targetColumn.lines.slice();
const originalTops = validateMove ? new Map([...sourceLines, ...targetLines].map((line) => [line, line.top])) : undefined;
const sourceWasFull = sourceColumn.isFull;
const firstTop = Math.min(...lines.map((line) => line.top));
const moved = lines.map((line) => {
line.top -= firstTop;
@@ -595,20 +603,35 @@ function _prependLines(
sourceColumn.isFull = false;
_reindexColumnLines(sourceColumn);
_reindexColumnLines(targetColumn);
if (validateMove && !validateMove()) {
sourceColumn.lines = sourceLines;
targetColumn.lines = targetLines;
sourceColumn.isFull = sourceWasFull;
originalTops?.forEach((top, line) => {
line.top = top;
});
_reindexColumnLines(sourceColumn);
_reindexColumnLines(targetColumn);
validateMove();
return 0;
}
return moved.length;
}
function _hasExplicitStructuralBreak(shapedTextList: IShapedText[]): boolean {
return shapedTextList.some(({ text, glyphs }) =>
_endsWithToken(text, glyphs, DataStreamTreeTokenType.PAGE_BREAK) ||
_endsWithToken(text, glyphs, DataStreamTreeTokenType.COLUMN_BREAK)
);
function _hasExplicitStructuralBreak(shapedTextList: IShapedText[], viewModel: DocumentViewModel, startIndex: number): boolean {
let offset = startIndex;
return shapedTextList.some(({ text, glyphs }) => {
offset += _glyphCount(glyphs);
return (_endsWithToken(text, glyphs, DataStreamTreeTokenType.PAGE_BREAK) && !_isRenderedPageBreak(viewModel, offset - 1)) ||
_endsWithToken(text, glyphs, DataStreamTreeTokenType.COLUMN_BREAK);
});
}
function _applyKeepLines(
pages: IDocumentSkeletonPage[],
paragraphIndex: number,
metrics: ILayoutContext['paginationMetrics']
metrics: ILayoutContext['paginationMetrics'],
validateMove?: () => boolean
): number {
const refs = _getParagraphLineRefs(pages, paragraphIndex);
if (metrics) {
@@ -631,7 +654,8 @@ function _applyKeepLines(
const movedLineCount = _prependLines(
sourceColumns[0],
targetColumns[0],
sourceRefs.map(({ line }) => line)
sourceRefs.map(({ line }) => line),
validateMove
);
if (metrics && movedLineCount > 0) {
metrics.retryCount += 1;
@@ -645,7 +669,8 @@ function _applyKeepNext(
originalPage: IDocumentSkeletonPage,
paragraphIndex: number,
paragraphConfigCache: Map<number, IParagraphConfig>,
metrics: ILayoutContext['paginationMetrics']
metrics: ILayoutContext['paginationMetrics'],
validateMove?: () => boolean
): number {
const refs = _getParagraphLineRefs(pages, paragraphIndex);
if (metrics) {
@@ -698,7 +723,7 @@ function _applyKeepNext(
if (start === sourceColumn.lines.length) {
return 0;
}
const movedLineCount = _prependLines(sourceColumn, targetColumn, sourceColumn.lines.slice(start));
const movedLineCount = _prependLines(sourceColumn, targetColumn, sourceColumn.lines.slice(start), validateMove);
if (metrics && movedLineCount > 0) {
metrics.retryCount += 1;
metrics.movedLineCount += movedLineCount;
@@ -709,7 +734,8 @@ function _applyKeepNext(
function _applyWidowControl(
pages: IDocumentSkeletonPage[],
paragraphIndex: number,
metrics: ILayoutContext['paginationMetrics']
metrics: ILayoutContext['paginationMetrics'],
validateMove?: () => boolean
): number {
const refs = _getParagraphLineRefs(pages, paragraphIndex);
if (metrics) {
@@ -726,17 +752,18 @@ function _applyWidowControl(
const targetPage = paragraphPages[paragraphPages.length - 1];
const sourceRefs = refs.filter(({ page }) => page === sourcePage);
const targetRefs = refs.filter(({ page }) => page === targetPage);
if (sourceRefs.length === 1 && targetRefs.length >= 2) {
const movedLineCount = _prependLines(sourceRefs[0].column, targetRefs[0].column, [sourceRefs[0].line]);
if (sourceRefs.length === 1 && targetRefs.length >= 1) {
const movedLineCount = _prependLines(sourceRefs[0].column, targetRefs[0].column, [sourceRefs[0].line], validateMove);
if (metrics && movedLineCount > 0) {
metrics.retryCount += 1;
metrics.movedLineCount += movedLineCount;
}
return movedLineCount;
}
if (targetRefs.length === 1 && sourceRefs.length >= 3) {
const line = sourceRefs[sourceRefs.length - 1];
const movedLineCount = _prependLines(line.column, targetRefs[0].column, [line.line]);
if (targetRefs.length === 1 && sourceRefs.length >= 2) {
// A three-line paragraph must move together; moving only its second line creates an orphan.
const movedRefs = sourceRefs.slice(sourceRefs.length === 2 ? 0 : -1);
const movedLineCount = _prependLines(movedRefs[0].column, targetRefs[0].column, movedRefs.map(({ line }) => line), validateMove);
if (metrics && movedLineCount > 0) {
metrics.retryCount += 1;
metrics.movedLineCount += movedLineCount;
@@ -944,6 +971,28 @@ interface ILayoutShapedTextsParams {
paragraphInlineSkeDrawingsByBlockId: Map<string, IDocumentSkeletonDrawing>;
}
function _splitAtReferencePageBreaks(shapedTexts: IShapedText[], offsets: ReadonlySet<number> | undefined): IShapedText[] {
if (!offsets?.size) {
return shapedTexts;
}
const result: IShapedText[] = [];
let offset = 0;
for (const shaped of shapedTexts) {
let start = 0;
for (let index = 0; index < shaped.glyphs.length; index++) {
if (index > start && offsets.has(offset)) {
const glyphs = shaped.glyphs.slice(start, index);
result.push({ ...shaped, glyphs, text: glyphs.map((glyph) => glyph.raw).join(''), breakPointType: BreakPointType.Normal });
start = index;
}
offset += shaped.glyphs[index].count;
}
const glyphs = shaped.glyphs.slice(start);
result.push(start === 0 ? shaped : { ...shaped, glyphs, text: glyphs.map((glyph) => glyph.raw).join('') });
}
return result;
}
function _layoutShapedTexts({
ctx,
viewModel,
@@ -966,19 +1015,32 @@ function _layoutShapedTexts({
let renderParagraphBullet = true;
let shapedTextOffset = 0;
let renderedPageBreakAnchorPage = curPage;
const mergedShapedTextList = _mergeAdjacentCustomBlockShapedTexts(shapedTextList, paragraphNonInlineSkeDrawingsByBlockId);
const retainedBreaks = ctx.footnoteLayout?.getReferencePageBreaks(paragraphNode.endIndex);
const referencePageBreaks = retainedBreaks
? new Set(retainedBreaks.map((offset) => offset - paragraphNode.startIndex))
: undefined;
const mergedShapedTextList = _mergeAdjacentCustomBlockShapedTexts(
_splitAtReferencePageBreaks(shapedTextList, referencePageBreaks),
paragraphNonInlineSkeDrawingsByBlockId
);
for (const { text, glyphs, breakPointType } of mergedShapedTextList) {
for (let shapedIndex = 0; shapedIndex < mergedShapedTextList.length; shapedIndex++) {
const { text, glyphs, breakPointType } = mergedShapedTextList[shapedIndex];
let layoutGlyphs = glyphs;
const textStartIndex = paragraphNode.startIndex + shapedTextOffset;
const lastPage = allPages[allPages.length - 1];
if (referencePageBreaks?.has(shapedTextOffset) && _hasPageContent(lastPage)) {
allPages.push(createSkeletonPage(ctx, sectionBreakConfig, skeletonResourceReference, _getNextPageNumber(lastPage)));
}
const textGlyphCount = _glyphCount(glyphs);
const textEndIndex = textStartIndex + textGlyphCount;
const pushPending = () => {
if (glyphs.length === 0) {
const pushPending = (layoutBreakPointType = breakPointType) => {
if (layoutGlyphs.length === 0) {
return;
}
syncActiveParagraphDrawings(
glyphs,
layoutGlyphs,
paragraphNonInlineSkeDrawings,
paragraphInlineSkeDrawings,
paragraphNonInlineSkeDrawingsByBlockId,
@@ -987,12 +1049,12 @@ function _layoutShapedTexts({
allPages = layoutParagraph(
ctx,
glyphs,
layoutGlyphs,
allPages,
sectionBreakConfig,
paragraphConfig,
isParagraphFirstShapedText || hasOnlyFloatingCustomBlockGlyphs(glyphs, paragraphNonInlineSkeDrawingsByBlockId),
breakPointType,
layoutBreakPointType,
renderParagraphBullet
);
@@ -1001,10 +1063,38 @@ function _layoutShapedTexts({
};
if (_endsWithToken(text, glyphs, DataStreamTreeTokenType.PAGE_BREAK)) {
pushPending();
const currentPage = allPages[allPages.length - 1];
const isRenderedPageBreak =
traditionalPagination && _isRenderedPageBreak(viewModel, textEndIndex - 1);
if (
traditionalPagination && !isRenderedPageBreak &&
viewModel.getDataModel().documentStyle.splitPageBreakAndParagraphMark !== BooleanNumber.TRUE
) {
const trailingGlyphs = mergedShapedTextList.slice(shapedIndex + 1).flatMap((item) => item.glyphs);
if (
trailingGlyphs.some((glyph) => glyph.raw === DataStreamTreeTokenType.PARAGRAPH) &&
trailingGlyphs.every((glyph) =>
glyph.raw === DataStreamTreeTokenType.PARAGRAPH || glyph.raw === DataStreamTreeTokenType.SECTION_BREAK
)
) {
// Word keeps the paragraph terminator on the break's line when no run content follows it.
// Retain its source offset and font style without making an empty line on the next page.
// A leading cell break does not advance a page, so its real paragraph still occupies height.
const retainParagraphHeight = _isInsideFlowTable(viewModel, textEndIndex - 1) &&
!_hasPageContent(allPages[allPages.length - 1]) &&
glyphs.every((glyph) => glyph.raw === DataStreamTreeTokenType.PAGE_BREAK);
layoutGlyphs = [...glyphs, ...trailingGlyphs.map((glyph) => retainParagraphHeight
? glyph
: ({
...glyph,
width: 0,
bBox: { ...glyph.bBox, width: 0, ba: 0, bd: 0, aba: 0, abd: 0, normalLineHeight: 0 },
}))];
shapedTextOffset += _glyphCount(trailingGlyphs);
shapedIndex = mergedShapedTextList.length - 1;
}
}
pushPending(isRenderedPageBreak ? BreakPointType.Normal : breakPointType);
const currentPage = allPages[allPages.length - 1];
const naturallyAdvancedInsideTable =
isRenderedPageBreak &&
_isInsideFlowTable(viewModel, textEndIndex - 1) &&
@@ -1033,7 +1123,7 @@ function _layoutShapedTexts({
}
renderedPageBreakAnchorPage = allPages[allPages.length - 1];
paragraphNonInlineSkeDrawings.clear();
isParagraphFirstShapedText = true;
isParagraphFirstShapedText = allPages[allPages.length - 1] !== currentPage || !isRenderedPageBreak;
shapedTextOffset += textGlyphCount;
continue;
} else if (
@@ -1103,6 +1193,7 @@ interface IApplyPaginationConstraintsParams {
resolvedParagraphStyle: IParagraphStyle;
segmentParagraphCache: Map<number, IParagraphConfig>;
traditionalPagination: boolean;
sectionBreakConfig: ISectionBreakConfig;
}
function _applyPaginationConstraints({
@@ -1117,7 +1208,10 @@ function _applyPaginationConstraints({
resolvedParagraphStyle,
segmentParagraphCache,
traditionalPagination,
sectionBreakConfig,
}: IApplyPaginationConstraintsParams): void {
let moved = false;
const validateMove = ctx.footnoteLayout ? () => ctx.footnoteLayout!.reconcilePages(allPages, sectionBreakConfig) : undefined;
const needsPaginationCheckpoint =
forcePageBreakBefore ||
(
@@ -1151,13 +1245,15 @@ function _applyPaginationConstraints({
// Apply Word-compatible soft constraints from strongest to weakest.
// Each helper only relocates already-shaped local lines and remains bounded.
if (resolvedParagraphStyle.keepLines === BooleanNumber.TRUE &&
_applyKeepLines(allPages, endIndex, ctx.paginationMetrics) > 0) {
_applyKeepLines(allPages, endIndex, ctx.paginationMetrics, validateMove) > 0) {
moved = true;
ctx.paragraphsOpenNewPage.add(endIndex);
}
const keepNextMoved = allPages.length > 1
? _applyKeepNext(allPages, curPage, endIndex, segmentParagraphCache, ctx.paginationMetrics)
? _applyKeepNext(allPages, curPage, endIndex, segmentParagraphCache, ctx.paginationMetrics, validateMove)
: 0;
if (keepNextMoved > 0) {
moved = true;
ctx.paragraphsOpenNewPage.add(endIndex);
}
let widowMoved = 0;
@@ -1165,16 +1261,21 @@ function _applyPaginationConstraints({
allPages.length > 1 &&
resolvedParagraphStyle.widowControl === BooleanNumber.TRUE
) {
widowMoved = _applyWidowControl(allPages, endIndex, ctx.paginationMetrics);
widowMoved = _applyWidowControl(allPages, endIndex, ctx.paginationMetrics, validateMove);
}
moved ||= widowMoved > 0;
// A widow adjustment must not orphan a stronger keep-next predecessor.
// Re-check only when the first keep-next pass made no move.
if (widowMoved > 0 &&
keepNextMoved === 0 &&
_applyKeepNext(allPages, curPage, endIndex, segmentParagraphCache, ctx.paginationMetrics) > 0) {
_applyKeepNext(allPages, curPage, endIndex, segmentParagraphCache, ctx.paginationMetrics, validateMove) > 0) {
moved = true;
ctx.paragraphsOpenNewPage.add(endIndex);
}
}
if (moved) {
ctx.footnoteLayout?.queueReferenceRelayout(allPages);
}
}
export function lineBreaking(
@@ -1208,7 +1309,7 @@ export function lineBreaking(
nextParagraphNode
);
const traditionalPagination = isTraditionalDocumentCompatibility(documentCompatibilityPolicy);
const explicitStructuralBreak = _hasExplicitStructuralBreak(shapedTextList);
const explicitStructuralBreak = _hasExplicitStructuralBreak(shapedTextList, viewModel, paragraphNode.startIndex);
const forcePageBreakBefore =
traditionalPagination &&
(resolvedParagraphStyle.pageBreakBefore === BooleanNumber.TRUE || tablePageBreakBefore) &&
@@ -1250,13 +1351,14 @@ export function lineBreaking(
allPages,
curPage,
endIndex,
explicitStructuralBreak,
explicitStructuralBreak: explicitStructuralBreak || allPages.slice(1).some((page) => page.isExplicitPageBreak),
forcePageBreakBefore,
tableSkeleton,
paragraphNonInlineSkeDrawingsByBlockId,
resolvedParagraphStyle,
segmentParagraphCache,
traditionalPagination,
sectionBreakConfig,
});
return allPages;
@@ -21,10 +21,17 @@ import type { DataStreamTreeNode } from '../../../view-model/data-stream-tree-no
import type { DocumentViewModel } from '../../../view-model/document-view-model';
import type { IBreakPoints } from '../../line-breaker/line-breaker';
import type { ILayoutContext } from '../../tools';
import { BooleanNumber, DataStreamTreeTokenType, GridType, PositionedObjectLayoutType } from '@univerjs/core';
import {
BaselineOffset,
BooleanNumber,
DataStreamTreeTokenType,
GridType,
PositionedObjectLayoutType,
} from '@univerjs/core';
import { cjk } from '../../../../../basics/cjk-regexp';
import { GlyphType } from '../../../../../basics/i-document-skeleton-cached';
import {
getFontStyleString,
hasArabic,
hasThai,
hasTibetan,
@@ -267,6 +274,20 @@ export function shaping(
const word = content.slice(last, bk.position);
const shapedGlyphs: IDocumentSkeletonGlyph[] = [];
if (last === 0 && ctx.noteLabel != null && viewModel === ctx.viewModel &&
paragraphNode.endIndex === viewModel.getBody()?.paragraphs?.[0]?.startIndex) {
const config = getFontCreateConfig(0, viewModel, paragraphNode, sectionBreakConfig, paragraph);
const textStyle = { ...config.textStyle, va: BaselineOffset.SUPERSCRIPT, ...ctx.noteReferenceTextStyle };
const marker = createSkeletonLetterGlyph(ctx.noteLabel, {
...config,
textStyle,
fontStyle: getFontStyleString({ ...textStyle, ff: [textStyle.ff, textStyle.eastAsiaFontFamily].filter(Boolean).join(', ') }),
});
marker.raw = '';
marker.count = 0;
shapedGlyphs.push(marker);
}
let src = word;
let i = last;
while (src.length > 0) {
@@ -304,7 +325,34 @@ export function shaping(
measuredWholeEntityRangeIndex++;
}
if (char === DataStreamTreeTokenType.CUSTOM_BLOCK) {
const fieldType = char === '\uFFFC'
? viewModel.getCustomRangeRaw(paragraphNode.startIndex + i)?.properties?.fieldType
: undefined;
const isSeparator = fieldType === 'FOOTNOTE_SEPARATOR' || fieldType === 'FOOTNOTE_CONTINUATION_SEPARATOR';
const note = viewModel === ctx.viewModel && char === '\uFFFC'
? ctx.noteReferences?.get(paragraphNode.startIndex + i)
: undefined;
if (isSeparator) {
const config = getFontCreateConfig(i, viewModel, paragraphNode, sectionBreakConfig, paragraph);
const textWidth = Math.max(0, (sectionBreakConfig.pageSize?.width ?? 192) -
(sectionBreakConfig.marginLeft ?? 0) - (sectionBreakConfig.marginRight ?? 0));
const width = fieldType === 'FOOTNOTE_SEPARATOR' ? Math.min(192, textWidth) : textWidth;
const glyph = createSkeletonLetterGlyph(' ', config, width);
glyph.raw = '\uFFFC';
glyph.noteSeparator = true;
shapedGlyphs.push(glyph);
i++;
src = src.substring(1);
} else if (note) {
const config = getFontCreateConfig(i, viewModel, paragraphNode, sectionBreakConfig, paragraph);
const glyph = createSkeletonLetterGlyph(note.label, config);
glyph.raw = '\uFFFC';
glyph.count = 1;
glyph.noteId = note.noteId;
shapedGlyphs.push(glyph);
i++;
src = src.substring(1);
} else if (char === DataStreamTreeTokenType.CUSTOM_BLOCK) {
const config = getFontCreateConfig(i, viewModel, paragraphNode, sectionBreakConfig, paragraph);
let newGlyph: Nullable<IDocumentSkeletonGlyph> = null;
const customBlock = viewModel.getCustomBlockWithoutSetCurrentIndex(paragraphNode.startIndex + i);
@@ -24,11 +24,12 @@ import type {
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 { DocumentFootnoteLayout } from '../footnote-layout';
import type { ICellSkeletonBuildState } from '../model/page';
import type { ILayoutContext } from '../tools';
import { BooleanNumber, TableAlignmentType, TableRowHeightRule, VerticalAlignmentType } from '@univerjs/core';
import { DocumentSkeletonPageType } from '../../../../basics/i-document-skeleton-cached';
import { getDocumentCompatibilityPolicy } from '../../document-compatibility';
import { getDocumentCompatibilityPolicy, isTraditionalDocumentCompatibility } from '../../document-compatibility';
import {
createNullCellPage,
createSkeletonCellPages,
@@ -281,7 +282,14 @@ function _finishTableSkeletonBuild(state: ITableSkeletonBuildState): void {
const { curPage, table, tableSkeleton } = state;
tableSkeleton.width = state.tableWidth;
tableSkeleton.height = state.rowTop;
const mergedRowHeightsChanged = isTraditionalDocumentCompatibility(state.sectionBreakConfig.documentCompatibilityPolicy ?? getDocumentCompatibilityPolicy()) &&
resolveMergedRowHeights(tableSkeleton);
applyMergedCellSpanHeights(tableSkeleton);
if (mergedRowHeightsChanged) {
for (const row of tableSkeleton.rows) {
_verticalAlignInCell(row, table.tableRows[row.index]);
}
}
const { pageWidth, marginLeft = 0, marginRight = 0 } = curPage;
tableSkeleton.left = getTableLeft(
@@ -337,6 +345,7 @@ export interface ISlicedTableSkeletonBuildState {
rowIndex: number;
columnIndex: number;
preparedCellPages: Map<number, IDocumentSkeletonPage[]>;
cellPageHeights?: number[];
pendingCellBuild: Nullable<ICellSkeletonBuildState>;
complete: boolean;
result: Nullable<ISlicedTableSkeletonParams>;
@@ -348,6 +357,8 @@ interface ICreateTableCache {
remainHeight: number;
repeatRows: DataStreamTreeNode[];
repeatRowsHeight: number;
notes?: ReturnType<DocumentFootnoteLayout['createTablePagination']>;
fromCurrentPage?: boolean;
}
export function startTableSkeletonsBuild(
@@ -380,6 +391,7 @@ export function startTableSkeletonsBuild(
remainHeight: availableHeight,
repeatRows: getLeadingRepeatHeaderRows(table, rowNodes),
repeatRowsHeight: 0,
notes: ctx.footnoteLayout?.createTablePagination(curPage, sectionBreakConfig, availableHeight),
},
rowIndex: 0,
columnIndex: 0,
@@ -407,7 +419,7 @@ export function stepTableSkeletonsBuild(state: ISlicedTableSkeletonBuildState):
const precomputedRow = precomputedTableSkeletons.get(state.ctx)?.get(state.tableNode.startIndex)?.rows[state.rowIndex];
// Pagination uses the same current-generation measurements as the synchronous
// path. Only cells in rows that actually need splitting must be laid out again.
const reusePrecomputedRow = canReusePrecomputedTableRow(state.curPage, state.createCache, rowSource, precomputedRow);
const reusePrecomputedRow = state.cellPageHeights == null && canReusePrecomputedTableRow(state.curPage, state.createCache, rowSource, precomputedRow);
if (!reusePrecomputedRow && cellNode != null && !isCoveredTableCell(cellConfig)) {
const pageContentHeight = getAvailableHeight(state.curPage, state.createCache, false);
const availableHeight = getAvailableHeight(state.curPage, state.createCache, true);
@@ -427,7 +439,8 @@ export function stepTableSkeletonsBuild(state: ISlicedTableSkeletonBuildState):
state.rowIndex,
state.columnIndex,
firstCellPageHeight,
pageContentHeight
pageContentHeight,
state.cellPageHeights
);
if (!stepSkeletonCellPagesBuild(state.pendingCellBuild)) {
return false;
@@ -442,7 +455,8 @@ export function stepTableSkeletonsBuild(state: ISlicedTableSkeletonBuildState):
state.rowIndex,
state.columnIndex,
firstCellPageHeight,
pageContentHeight
pageContentHeight,
state.cellPageHeights
)
: state.pendingCellBuild.pages;
state.pendingCellBuild = null;
@@ -454,7 +468,7 @@ export function stepTableSkeletonsBuild(state: ISlicedTableSkeletonBuildState):
return false;
}
dealWithTableRow(
const nextHeights = dealWithTableRow(
state.ctx,
state.curPage,
state.skeTables,
@@ -466,9 +480,17 @@ export function stepTableSkeletonsBuild(state: ISlicedTableSkeletonBuildState):
state.createCache,
false,
precomputedRow,
state.preparedCellPages
state.preparedCellPages,
state.cellPageHeights
);
if (nextHeights) {
state.cellPageHeights = nextHeights;
state.columnIndex = 0;
state.preparedCellPages = new Map();
return false;
}
state.rowIndex++;
state.cellPageHeights = undefined;
state.columnIndex = 0;
state.preparedCellPages = new Map();
state.pendingCellBuild = null;
@@ -500,6 +522,7 @@ function _finishTableSkeletonsBuild(state: ISlicedTableSkeletonBuildState): void
state.result = {
skeTables: state.skeTables,
fromCurrentPage:
state.createCache.fromCurrentPage !== false &&
state.skeTables[0].height <=
state.availableHeight + policy.table.currentPageOverflowTolerance,
};
@@ -563,6 +586,7 @@ export function createTableSkeletons(
remainHeight: availableHeight,
repeatRows,
repeatRowsHeight: 0,
notes: ctx.footnoteLayout?.createTablePagination(curPage, sectionBreakConfig, availableHeight),
};
skeTables.push(curTableSkeleton);
@@ -570,25 +594,31 @@ export function createTableSkeletons(
for (const rowNode of rowNodes) {
const row = rowNodes.indexOf(rowNode);
dealWithTableRow(
ctx,
curPage,
skeTables,
viewModel,
sectionBreakConfig,
rowNode,
row,
table,
createCache,
false,
precomputedTable?.rows[row]
);
let cellPageHeights: number[] | undefined;
do {
cellPageHeights = dealWithTableRow(
ctx,
curPage,
skeTables,
viewModel,
sectionBreakConfig,
rowNode,
row,
table,
createCache,
false,
precomputedTable?.rows[row],
undefined,
cellPageHeights
);
} while (cellPageHeights);
}
updateTableSkeletonsPosition(createCache, curPage, skeTables, table);
const documentCompatibilityPolicy = sectionBreakConfig.documentCompatibilityPolicy ?? getDocumentCompatibilityPolicy();
const fromCurrentPage =
createCache.fromCurrentPage !== false &&
skeTables[0].height <= availableHeight + documentCompatibilityPolicy.table.currentPageOverflowTolerance;
return {
@@ -666,8 +696,9 @@ function dealWithTableRow(
cache: ICreateTableCache,
isRepeatRow = false,
precomputedRow?: IDocumentSkeletonRow,
preparedCellPages?: Map<number, IDocumentSkeletonPage[]>
) {
preparedCellPages?: Map<number, IDocumentSkeletonPage[]>,
cellPageHeights?: readonly number[]
): number[] | undefined {
const pageContentHeight = getAvailableHeight(curPage, cache, false);
const availableHeight = getAvailableHeight(curPage, cache, true);
const documentCompatibilityPolicy = sectionBreakConfig.documentCompatibilityPolicy ?? getDocumentCompatibilityPolicy();
@@ -677,7 +708,7 @@ function dealWithTableRow(
const { hRule, val } = trHeight;
const canRowSplit = cantSplit !== BooleanNumber.TRUE && trHeight.hRule !== TableRowHeightRule.EXACT;
const needOpenNewTable = cache.remainHeight <= 0;
const precomputedRowFits = !isRepeatRow && canReusePrecomputedTableRow(curPage, cache, rowSource, precomputedRow);
const precomputedRowFits = !isRepeatRow && cellPageHeights == null && canReusePrecomputedTableRow(curPage, cache, rowSource, precomputedRow);
const rowSkeletons: IDocumentSkeletonRow[] = precomputedRowFits ? [precomputedRow] : [];
let curTableSkeleton = getCurTableSkeleton(skeTables);
@@ -713,7 +744,8 @@ function dealWithTableRow(
row,
col,
canRowSplit && !needOpenNewTable ? cache.remainHeight : availableHeight,
pageContentHeight
pageContentHeight,
cellPageHeights
);
while (rowSkeletons.length < cellPageSkeletons.length) {
rowSkeletons.push(createNullRowSkeletonWithCells(
@@ -786,6 +818,22 @@ function dealWithTableRow(
rowSke.height = rowHeights[rowIndex];
}
if (!isRepeatRow && cache.notes) {
let nextHeights: number[] | undefined;
if (canRowSplit) {
nextHeights = cache.notes.measureRow(curTableSkeleton, rowSkeletons, cellPageHeights);
} else if (cache.notes.renumberWholeRow(curTableSkeleton, rowSkeletons, cache.remainHeight)) {
nextHeights = [];
}
if (nextHeights) {
const lists = ctx.skeletonResourceReference.skeListLevel;
if (lists) {
rollbackListCache(lists, rowNode);
}
return nextHeights;
}
}
if (!isRepeatRow && row < cache.repeatRows.length) {
cache.repeatRowsHeight += rowHeights.reduce((total, height) => total + height, 0);
}
@@ -800,13 +848,19 @@ function dealWithTableRow(
const rowSkeleton = rowSkeletons.shift()!;
const lastRow = curTableSkeleton.rows[curTableSkeleton.rows.length - 1];
const rowOverflowHeight = rowSkeleton.height - cache.remainHeight;
const shouldOpenNewTable =
const geometryOverflow =
cache.remainHeight <= 0 ||
forcedPageBreakRows.has(rowSkeleton) ||
rowOverflowHeight > documentCompatibilityPolicy.table.rowOverflowTolerance;
let remainingWithNotes = geometryOverflow ? undefined : cache.notes?.append(curTableSkeleton, rowSkeleton);
const shouldOpenNewTable = geometryOverflow || (cache.notes != null && remainingWithNotes == null);
if (shouldOpenNewTable) {
cache.remainHeight = getAvailableHeight(curPage, cache, row !== 0 && rowSkeleton.index !== lastRow.index);
cache.notes?.nextPage();
if (cache.notes && skeTables.length === 1 && curTableSkeleton.rows.length === 0) {
cache.fromCurrentPage = false;
}
cache.remainHeight = getAvailableHeight(curPage, cache, row !== 0 && rowSkeleton.index !== lastRow?.index);
cache.rowTop = 0;
if (curTableSkeleton.rows.length > 0) {
@@ -833,6 +887,11 @@ function dealWithTableRow(
});
}
}
remainingWithNotes = cache.notes?.append(getCurTableSkeleton(skeTables), rowSkeleton);
while (remainingWithNotes == null && cache.notes?.hasContinuation()) {
cache.notes.nextPage();
remainingWithNotes = cache.notes.append(getCurTableSkeleton(skeTables), rowSkeleton);
}
}
curTableSkeleton = getCurTableSkeleton(skeTables);
@@ -842,7 +901,7 @@ function dealWithTableRow(
curTableSkeleton.rows.push(rowSkeleton);
rowSkeleton.parent = curTableSkeleton;
cache.remainHeight -= rowSkeleton.height;
cache.remainHeight = remainingWithNotes ?? cache.remainHeight - rowSkeleton.height;
cache.rowTop += rowSkeleton.height;
}
@@ -975,6 +1034,60 @@ function applyMergedCellSpanHeights(tableSkeleton: IDocumentSkeletonTable): void
});
}
function resolveMergedRowHeights(tableSkeleton: IDocumentSkeletonTable): boolean {
const rows = tableSkeleton.rows;
const sources = tableSkeleton.tableSource.tableRows;
if (!sources.some((row) => row.tableCells.some((cell) => (cell.rowSpan ?? 1) > 1))) {
return false;
}
// A merged cell constrains the combined height of its rows, not just the first row.
// First measure independent cells and explicit row minima, then satisfy each span.
const cellHeight = (cell: IDocumentSkeletonPage) =>
cell.height + (cell.originMarginTop ?? 0) + (cell.originMarginBottom ?? 0);
for (const row of rows) {
const source = sources[row.index];
const { hRule, val } = source.trHeight;
const contentHeight = row.cells.reduce((height, cell, column) =>
(source.tableCells[column].rowSpan ?? 1) > 1 || isCoveredTableCell(source.tableCells[column])
? height
: Math.max(height, cellHeight(cell)), 0);
row.height = contentHeight;
if (hRule === TableRowHeightRule.EXACT) {
row.height = val.v;
} else if (hRule === TableRowHeightRule.AT_LEAST) {
row.height = Math.max(contentHeight, val.v);
}
}
for (const row of rows) {
sources[row.index].tableCells.forEach((source, column) => {
if ((source.rowSpan ?? 1) <= 1) {
return;
}
const span = rows.slice(row.index, row.index + source.rowSpan!);
const extraHeight = cellHeight(row.cells[column]) - span.reduce((height, item) => height + item.height, 0);
const expandable = span.filter((item) => sources[item.index].trHeight.hRule !== TableRowHeightRule.EXACT);
if (extraHeight > 0 && expandable.length > 0) {
for (const item of expandable) {
item.height += extraHeight / expandable.length;
}
}
});
}
let top = 0;
for (const row of rows) {
row.top = top;
top += row.height;
for (const cell of row.cells) {
cell.pageHeight = row.height;
}
}
tableSkeleton.height = top;
return true;
}
function createMergedAwareNullCellPage(
ctx: ILayoutContext,
sectionBreakConfig: ISectionBreakConfig,
@@ -103,11 +103,14 @@ import {
hydrateDocumentSkeletonPageMaterializationPlaceholder,
serializeDocumentSkeletonPage,
} from './document-layout-page-patch';
import { DocumentEndnoteLayout, getEndnoteFlowBottom } from './endnote-layout';
import { DocumentFootnoteLayout } from './footnote-layout';
import { Hyphen } from './hyphenation/hyphen';
import { Lang } from './hyphenation/lang';
import { LanguageDetector } from './hyphenation/language-detector';
import { createSkeletonPage } from './model/page';
import { createSkeletonSection } from './model/section';
import { resolveNoteReferences } from './note-numbering';
import {
getLastNotFullColumnInfo,
getLastPage,
@@ -361,7 +364,7 @@ interface IIncrementalLayoutState {
allowMetadataOnlyStructuralTailReuse: boolean;
reusedTail: boolean;
tailConvergencePageCount: number;
dirtyRetryCount: number;
dirtyRetries: Map<number, number>;
preparedHyphenationNodes: WeakSet<DataStreamTreeNode>;
pendingHyphenation: Promise<void> | null;
hyphenationError: Error | null;
@@ -591,6 +594,9 @@ function copyPageBoundaryMetadata(
}
function shiftPageCharacterOffsets(page: IDocumentSkeletonPage, delta: number): void {
for (const note of page.notes ?? []) {
note.referenceIndex += delta;
}
if (delta === 0) {
return;
}
@@ -755,6 +761,9 @@ function hasReusablePaginatedTailBoundary(
invalidation: IDocumentLayoutInvalidation,
allowMetadataOnlyStructuralTailReuse: boolean
): boolean {
if (previousPage.notes?.length || currentPage.notes?.length) {
return false;
}
const mutationDelta = invalidation.newEnd - invalidation.oldEnd;
const hasStructuralContainers = previousPage.skeTables.size > 0 ||
previousPage.skeColumnGroups.size > 0 ||
@@ -851,6 +860,15 @@ function getPagePath(page: IDocumentSkeletonPage) {
// eslint-disable-next-line ts/no-explicit-any
let parent: any = skeNode.parent;
while (parent) {
if (parent.page === skeNode && parent.noteId && parent.parent?.notes) {
const index = parent.parent.notes.indexOf(parent);
if (index !== -1) {
path.unshift('notes', index, 'page');
}
skeNode = parent.parent;
parent = skeNode.parent;
continue;
}
if (parent.page === skeNode && parent.parent?.columns) {
const index = parent.parent.columns.indexOf(parent);
@@ -997,6 +1015,12 @@ function serializePaginatedContinuationCheckpoint(page: IDocumentSkeletonPagePat
// protected range are complete, self-contained page publications. The
// checkpoint only needs to prove that the next page starts at the same
// logical offset and with the same paginated section configuration.
notes: page.notes?.map((note) => ({
noteId: note.noteId,
referenceIndex: note.referenceIndex,
continued: note.continued,
page: serializePaginatedContinuationCheckpoint(note.page),
})),
tables: page.skeTables.map(([tableId, table]) => ({
tableId,
st: table.st,
@@ -1140,8 +1164,6 @@ export class DocumentSkeleton extends Skeleton {
private _languageDetector = LanguageDetector.getInstance();
private _iteratorCount = 0;
private _initialWidth = 0;
private _paginationMetrics: Nullable<IDocumentPaginationMetrics> = null;
@@ -1354,7 +1376,7 @@ export class DocumentSkeleton extends Skeleton {
allowMetadataOnlyStructuralTailReuse: options?.allowMetadataOnlyStructuralTailReuse === true,
reusedTail: false,
tailConvergencePageCount: incrementalStart.ctx.skeleton.pages.length,
dirtyRetryCount: 0,
dirtyRetries: new Map(),
preparedHyphenationNodes: new WeakSet(),
pendingHyphenation: null,
hyphenationError: null,
@@ -1442,7 +1464,8 @@ export class DocumentSkeleton extends Skeleton {
blocks[anchorBlockIndex].block.children.length === 0;
// A Modern page can contain the entire document. Paginated tail reuse
// deep-clones that page in one atomic step, bypassing the slice budget.
if (mode === 'paginated' && reuseInteractionPagePrefix && canResumePlainParagraph) {
if (mode === 'paginated' && reuseInteractionPagePrefix && canResumePlainParagraph &&
!previousSkeleton.pages[anchorPageIndex]?.notes?.length) {
const interactionPreviousAnchor = mapCurrentOffsetToPrevious(anchor, invalidation);
const interactionPageIndex = interactionSkeleton == null
? -1
@@ -1494,7 +1517,8 @@ export class DocumentSkeleton extends Skeleton {
// Include page zero: an explicit page-break paragraph can rewind there
// while still pointing past the cover content that must also be rebuilt.
while (startPageIndex >= 0) {
const pageStart = getFirstBodyFlowCharIndex(previousSkeleton.pages[startPageIndex]);
const previousPage = previousSkeleton.pages[startPageIndex];
const pageStart = Math.min(getFirstBodyFlowCharIndex(previousPage), ...(previousPage.notes ?? []).map((note) => note.referenceIndex));
const currentPageStart = mapPreviousOffsetToCurrent(pageStart, invalidation);
const firstPageBlockIndex = blocks.findIndex(({ block }) => blockContainsOffset(block, currentPageStart));
if (firstPageBlockIndex < 0 || firstPageBlockIndex >= startBlockIndex) {
@@ -2970,6 +2994,7 @@ export class DocumentSkeleton extends Skeleton {
break;
}
case DocumentSkeletonPageType.NOTE:
case DocumentSkeletonPageType.CELL: {
pageIndex = typeof path[1] === 'number' ? path[1] : segmentPage;
break;
@@ -3049,6 +3074,7 @@ export class DocumentSkeleton extends Skeleton {
break;
}
case DocumentSkeletonPageType.NOTE:
case DocumentSkeletonPageType.CELL: {
pageIndex = typeof path[1] === 'number' ? path[1] : segmentPageIndex;
break;
@@ -3276,6 +3302,32 @@ export class DocumentSkeleton extends Skeleton {
}
const pageLength = pages.length;
this._findLiquid.reset();
if (restrictions == null || restrictions.strict === false) {
for (let pi = 0; pi < pageLength; pi++) {
const page = pages[pi];
let insideNoteArea = false;
for (const note of page.notes ?? []) {
const left = this._findLiquid.x + note.left;
const top = this._findLiquid.y + note.top;
if (x < left || x > left + note.page.pageWidth || y < top || y > top + note.page.height) {
continue;
}
insideNoteArea = true;
const noteCache: INearestCache = { nearestNodeList: [], nearestNodeDistanceList: [] };
const match = this._collectNearestNode(note.page, DocumentSkeletonPageType.NOTE, page, note.noteId, pi, noteCache, x, y, pageLength);
if (match) {
return match;
}
cache.nearestNodeList.push(...noteCache.nearestNodeList);
cache.nearestNodeDistanceList.push(...noteCache.nearestNodeDistanceList);
}
if (insideNoteArea) {
return this._getNearestNode(cache.nearestNodeList, cache.nearestNodeDistanceList);
}
this._translatePage(page, pageLayoutType, pageMarginLeft, pageMarginTop);
}
}
this._findLiquid.reset();
if (restrictions == null) {
for (let pi = 0; pi < pageLength; pi++) {
@@ -3341,12 +3393,25 @@ export class DocumentSkeleton extends Skeleton {
const { segmentId, segmentPage, strict } = restrictions;
let exactMatch = null;
if (strict && this._docViewModel.getNoteTreeMap().has(segmentId)) {
for (let pi = 0; pi < pageLength; pi++) {
const page = pages[pi];
for (const note of page.notes ?? []) {
if (note.noteId === segmentId) {
exactMatch = this._collectNearestNode(note.page, DocumentSkeletonPageType.NOTE, page, segmentId, pi, cache, x, y, pageLength);
}
}
this._translatePage(page, pageLayoutType, pageMarginLeft, pageMarginTop);
}
return exactMatch ?? this._getNearestNode(cache.nearestNodeList, cache.nearestNodeDistanceList);
}
if (strict === false) {
for (let pi = 0; pi < pageLength; pi++) {
const page = pages[pi];
const { headerId, footerId, pageWidth } = page;
if (segmentId !== '') {
if (segmentId !== '' && !this._docViewModel.getNoteTreeMap().has(segmentId)) {
const headerSke = skeHeaders.get(headerId)?.get(pageWidth) as IDocumentSkeletonPage;
if (headerSke) {
@@ -3468,10 +3533,13 @@ export class DocumentSkeleton extends Skeleton {
const { sections, skeTables, skeColumnGroups = new Map() } = segmentPage;
this._findLiquid.translateSave();
const pageLeft = this._findLiquid.x;
const pageRight = pageLeft + page.pageWidth; // Use page.pageWidth instead of segmentPage.pageWidth, because the segmentPage not include the margin left and right.
const pageTop = this._findLiquid.y + (pageType === DocumentSkeletonPageType.FOOTER ? page.pageHeight - segmentPage.pageHeight : 0);
const pageBottom = pageTop + segmentPage.pageHeight;
const note = pageType === DocumentSkeletonPageType.NOTE
? page.notes?.find((fragment) => fragment.page === segmentPage)
: undefined;
const pageLeft = this._findLiquid.x + (note?.left ?? 0);
const pageRight = pageLeft + (note ? segmentPage.pageWidth : page.pageWidth);
const pageTop = this._findLiquid.y + (note?.top ?? (pageType === DocumentSkeletonPageType.FOOTER ? page.pageHeight - segmentPage.pageHeight : 0));
const pageBottom = pageTop + (note ? segmentPage.height : segmentPage.pageHeight);
let pointInPage = x >= pageLeft
&& x <= pageRight
@@ -3498,6 +3566,10 @@ export class DocumentSkeleton extends Skeleton {
}
switch (pageType) {
case DocumentSkeletonPageType.NOTE: {
this._findLiquid.translate(note?.left ?? page.marginLeft, note?.top ?? page.marginTop);
break;
}
case DocumentSkeletonPageType.HEADER: {
this._findLiquid.translatePagePadding({
...segmentPage,
@@ -4440,6 +4512,7 @@ export class DocumentSkeleton extends Skeleton {
}
if (result.complete) {
ctx.endnoteLayout?.finishSection(ctx.skeleton.pages, state.sectionIndex, sectionBreakConfig);
const nextColumnProperties = state.sectionIndex + 1 < sections.length
? prepareSectionBreakConfig(ctx, state.sectionIndex + 1).columnProperties ?? []
: [];
@@ -4740,13 +4813,13 @@ export class DocumentSkeleton extends Skeleton {
// incremental scheduler. A wrapped floating object can invalidate lines that
// precede its anchor; completing that retry synchronously would reintroduce the
// exact long main-thread task that incremental pagination is meant to remove.
if (layoutAnchor == null || state.dirtyRetryCount >= 10) {
if (layoutAnchor == null || (state.dirtyRetries.get(layoutAnchor) ?? 0) >= 10) {
resetContext(ctx);
this._finishIncrementalLayout(state);
return;
}
state.dirtyRetryCount++;
state.dirtyRetries.set(layoutAnchor, (state.dirtyRetries.get(layoutAnchor) ?? 0) + 1);
resetContext(ctx);
ctx.layoutStartPointer[''] = null;
@@ -4814,6 +4887,10 @@ export class DocumentSkeleton extends Skeleton {
}
removeDupPages(ctx);
mergeContinuousDuplicatePages(skeleton.pages, state.mode === 'continuous');
if (ctx.footnoteLayout) {
ctx.footnoteLayout.finish(skeleton.pages, prepareSectionBreakConfig(ctx, ctx.viewModel.getChildren().length - 1));
updateBlockIndex(skeleton.pages, -1, ctx.docsConfig.documentCompatibilityPolicy);
}
if (state.mode === 'continuous') {
updateBlockIndex(skeleton.pages, -1, ctx.docsConfig.documentCompatibilityPolicy);
updateInlineDrawingCoordsAndBorder(ctx, skeleton.pages);
@@ -4831,7 +4908,6 @@ export class DocumentSkeleton extends Skeleton {
setPageParent(skeleton.pages, skeleton);
state.complete = true;
state.stableLaidOutThrough = state.laidOutThrough;
this._iteratorCount = 0;
}
private _prepareLayoutContext(): ILayoutContext {
@@ -4875,9 +4951,12 @@ export class DocumentSkeleton extends Skeleton {
drawingAnchor,
};
return {
const ctx: ILayoutContext = {
viewModel,
dataModel,
noteReferences: documentStyle.documentFlavor === DocumentFlavor.TRADITIONAL
? resolveNoteReferences(dataModel.getSnapshot())
: undefined,
skeleton,
skeletonResourceReference,
docsConfig,
@@ -4901,6 +4980,11 @@ export class DocumentSkeleton extends Skeleton {
hyphen: this._hyphen,
languageDetector: this._languageDetector,
};
if (ctx.noteReferences?.size) {
ctx.footnoteLayout = new DocumentFootnoteLayout(ctx);
ctx.endnoteLayout = new DocumentEndnoteLayout(ctx);
}
return ctx;
}
/**
@@ -4932,7 +5016,45 @@ export class DocumentSkeleton extends Skeleton {
*/
private _createSkeleton(ctx: ILayoutContext, _bounds?: IViewportInfo): IDocumentSkeletonCached {
// console.log('createSkeleton: iterate ', this._iteratorCount, 'times');
const retries = new Map<number, number>();
while (true) {
this._createSkeletonPass(ctx);
const anchor = ctx.layoutStartPointer[''];
if (!ctx.isDirty || anchor == null || (retries.get(anchor) ?? 0) >= 10) {
break;
}
// Bound retries at the same paragraph, not across independent reflows
// throughout a long document. Iterate instead of growing the call stack.
retries.set(anchor, (retries.get(anchor) ?? 0) + 1);
resetContext(ctx);
}
const { skeleton, viewModel } = ctx;
// Calculate page and section position information
removeDupPages(ctx);
ctx.footnoteLayout?.finish(skeleton.pages, prepareSectionBreakConfig(ctx, viewModel.getChildren().length - 1));
updateBlockIndex(skeleton.pages, -1, ctx.docsConfig.documentCompatibilityPolicy);
mergeContinuousDuplicatePages(
skeleton.pages,
ctx.dataModel.documentStyle.documentFlavor === DocumentFlavor.MODERN
);
// Calculate inline drawing position and update.
updateInlineDrawingCoordsAndBorder(ctx, skeleton.pages);
for (const hSkeMap of skeleton.skeHeaders.values()) {
for (const page of hSkeMap.values()) {
updateInlineDrawingCoordsAndBorder(ctx, [page]);
}
}
for (const fSkeMap of skeleton.skeFooters.values()) {
for (const page of fSkeMap.values()) {
updateInlineDrawingCoordsAndBorder(ctx, [page]);
}
}
setPageParent(skeleton.pages, skeleton);
return skeleton;
}
private _createSkeletonPass(ctx: ILayoutContext): void {
const { viewModel, skeleton, skeletonResourceReference } = ctx;
const allSkeletonPages = skeleton.pages;
@@ -5090,43 +5212,13 @@ export class DocumentSkeleton extends Skeleton {
}
allSkeletonPages.push(...pages);
ctx.endnoteLayout?.finishSection(allSkeletonPages, i, sectionBreakConfig);
// The page needs to be reflowed due to floating objects.
if (ctx.isDirty) {
break;
}
}
// TODO: 10 is too small?
if (ctx.isDirty && this._iteratorCount < 10) {
this._iteratorCount++;
resetContext(ctx);
return this._createSkeleton(ctx, _bounds);
} else {
// Calculate page and section position information
this._iteratorCount = 0;
removeDupPages(ctx);
updateBlockIndex(skeleton.pages, -1, ctx.docsConfig.documentCompatibilityPolicy);
mergeContinuousDuplicatePages(
skeleton.pages,
ctx.dataModel.documentStyle.documentFlavor === DocumentFlavor.MODERN
);
// Calculate inline drawing position and update.
updateInlineDrawingCoordsAndBorder(ctx, skeleton.pages);
for (const hSkeMap of skeleton.skeHeaders.values()) {
for (const page of hSkeMap.values()) {
updateInlineDrawingCoordsAndBorder(ctx, [page]);
}
}
for (const fSkeMap of skeleton.skeFooters.values()) {
for (const page of fSkeMap.values()) {
updateInlineDrawingCoordsAndBorder(ctx, [page]);
}
}
setPageParent(skeleton.pages, skeleton);
return skeleton;
}
}
// A page with multiple sections only occurs in SectionType.CONTINUOUS
@@ -5147,7 +5239,7 @@ export class DocumentSkeleton extends Skeleton {
} = curSkeletonPage;
const pageContentWidth = pageWidth - curPageML - curPageMR;
const pageContentHeight = pageHeight - curPageMT - curPageMB;
const lastSectionBottom = (lastSection?.top || 0) + (lastSection?.height || 0);
const lastSectionBottom = Math.max((lastSection?.top || 0) + (lastSection?.height || 0), getEndnoteFlowBottom(curSkeletonPage));
const newSection = createSkeletonSection(
columnProperties,
columnSeparatorType,
@@ -5234,10 +5326,13 @@ export class DocumentSkeleton extends Skeleton {
}
const { pages, skeFooters, skeHeaders } = skeletonData;
// Unlike repeated headers/footers, a note offset identifies one
// fragment. Its previous physical page is only a presentation hint.
const isRepeatedSegment = segmentId !== '' && (skeHeaders.has(segmentId) || skeFooters.has(segmentId));
for (const page of pages) {
const curPageIndex = pages.indexOf(page);
if (segmentId && curPageIndex !== segmentPageIndex) {
if (isRepeatedSegment && segmentPageIndex >= 0 && curPageIndex !== segmentPageIndex) {
continue;
}
@@ -5247,7 +5342,11 @@ export class DocumentSkeleton extends Skeleton {
if (segmentId) {
const maybeHeaderSke = skeHeaders.get(segmentId)?.get(pageWidth);
const maybeFooterSke = skeFooters.get(segmentId)?.get(pageWidth);
if (maybeHeaderSke) {
const note = page.notes?.find((fragment) => fragment.noteId === segmentId &&
fragment.page.st <= charIndex && charIndex <= fragment.page.ed);
if (note) {
segmentPage = note.page;
} else if (maybeHeaderSke) {
segmentPage = maybeHeaderSke;
} else if (maybeFooterSke) {
segmentPage = maybeFooterSke;
@@ -5327,7 +5426,7 @@ export class DocumentSkeleton extends Skeleton {
line,
divide,
glyph,
segmentPageIndex,
segmentPageIndex: segmentId ? curPageIndex : segmentPageIndex,
};
}
}
@@ -24,6 +24,8 @@ import type {
IDocumentSkeletonDrawing,
IDocumentSkeletonGlyph,
IDocumentSkeletonLine,
IDocumentSkeletonNote,
IDocumentSkeletonNoteDecoration,
IDocumentSkeletonPage,
IDocumentSkeletonRow,
IDocumentSkeletonSection,
@@ -74,17 +76,19 @@ export interface IDocumentSkeletonColumnGroupPatch extends Omit<IDocumentSkeleto
export interface IDocumentSkeletonPagePatch extends Omit<
IDocumentSkeletonPage,
'sections' | 'skeDrawings' | 'skeTables' | 'skeColumnGroups' | 'parent'
'sections' | 'skeDrawings' | 'skeTables' | 'skeColumnGroups' | 'notes' | 'noteDecorations' | 'parent'
> {
sections: IDocumentSkeletonSectionPatch[];
skeDrawings: Array<[string, IDocumentSkeletonDrawingPatch]>;
skeTables: Array<[string, IDocumentSkeletonTablePatch]>;
skeColumnGroups: Array<[string, IDocumentSkeletonColumnGroupPatch]>;
noteDecorations?: Array<Omit<IDocumentSkeletonNoteDecoration, 'page'> & { page: IDocumentSkeletonPagePatch }>;
notes?: Array<Omit<IDocumentSkeletonNote, 'page' | 'parent'> & { page: IDocumentSkeletonPagePatch }>;
}
export type IDocumentSkeletonPageGeometryPatch = Omit<
IDocumentSkeletonPagePatch,
'sections' | 'skeDrawings' | 'skeTables' | 'skeColumnGroups'
'sections' | 'skeDrawings' | 'skeTables' | 'skeColumnGroups' | 'notes' | 'noteDecorations'
>;
export type IDocumentSkeletonSectionGeometryPatch = Omit<IDocumentSkeletonSectionPatch, 'columns'>;
@@ -206,9 +210,16 @@ export function serializeDocumentSkeletonPage(
source: IDocumentSkeletonPage,
omitResourceSources = false
): IDocumentSkeletonPagePatch {
const { notes, noteDecorations, ...geometry } = withoutParent(source);
return {
...withoutParent(source),
...geometry,
sections: source.sections.map(serializeSection),
noteDecorations: noteDecorations?.map((decoration) => ({ ...decoration, page: serializeDocumentSkeletonPage(decoration.page, omitResourceSources) })),
...(notes == null
? {}
: {
notes: notes.map((note) => ({ ...withoutParent(note), page: serializeDocumentSkeletonPage(note.page, omitResourceSources) })),
}),
skeDrawings: [...source.skeDrawings].map(([drawingId, drawing]) => [
drawingId,
serializeDrawing(drawing, omitResourceSources),
@@ -271,7 +282,7 @@ function getDocumentReferenceSource(
snapshot: IDocumentData,
segmentId: string
): IReferenceSource {
return snapshot.headers?.[segmentId] ?? snapshot.footers?.[segmentId] ?? snapshot;
return snapshot.headers?.[segmentId] ?? snapshot.footers?.[segmentId] ?? snapshot.notes?.[segmentId] ?? snapshot;
}
function requireDrawingOrigin(
@@ -409,6 +420,8 @@ export function hydrateDocumentSkeletonPagePlaceholder(
): IDocumentSkeletonPage {
const {
sections: _sections,
notes: _notes,
noteDecorations: _noteDecorations,
skeDrawings: _skeDrawings,
skeTables: _skeTables,
skeColumnGroups: _skeColumnGroups,
@@ -448,6 +461,14 @@ function hydrateDocumentSkeletonPageInternal(
const page: IDocumentSkeletonPage = {
...source,
sections: [],
noteDecorations: source.noteDecorations?.map((decoration) => ({
...decoration,
page: hydrateDocumentSkeletonPageInternal(decoration.page, undefined, snapshot, decoration.page.segmentId),
})),
notes: source.notes?.map((note) => ({
...note,
page: hydrateDocumentSkeletonPageInternal(note.page, undefined, snapshot, note.noteId),
})),
skeDrawings: new Map(source.skeDrawings.map(([drawingId, drawing]) => [
drawingId,
hydrateDrawing(drawing, snapshot, resourceSegmentId),
@@ -457,6 +478,10 @@ function hydrateDocumentSkeletonPageInternal(
parent,
};
page.sections = source.sections.map((section) => hydrateSection(section, page));
for (const note of page.notes ?? []) {
note.parent = page;
note.page.parent = note;
}
page.skeTables = new Map(source.skeTables.map(([tableId, table]) => [
tableId,
hydrateTable(table, page, snapshot, resourceSegmentId),
@@ -549,6 +574,8 @@ function serializePageGeometry(source: IDocumentSkeletonPage): IDocumentSkeleton
const serialized = withoutParent(source);
const {
sections: _sections,
notes: _notes,
noteDecorations: _noteDecorations,
skeDrawings: _skeDrawings,
skeTables: _skeTables,
skeColumnGroups: _skeColumnGroups,
@@ -0,0 +1,144 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { IDocumentSkeletonPage } from '../../../basics/i-document-skeleton-cached';
import type { ISectionBreakConfig } from '../../../basics/interfaces';
import type { ILayoutContext } from './tools';
import { DocumentDataModel } from '@univerjs/core';
import { DocumentViewModel } from '../view-model/document-view-model';
import { defaultSeparatorBody, layoutNoteBody, layoutNoteSegment } from './footnote-layout';
import { createSkeletonPage } from './model/page';
import { getNoteSections } from './note-numbering';
/** Endnotes occupy document flow after a section or the final body section. */
export class DocumentEndnoteLayout {
constructor(private readonly _ctx: ILayoutContext) {}
finishSection(pages: IDocumentSkeletonPage[], sectionIndex: number, config: ISectionBreakConfig): void {
const snapshot = this._ctx.dataModel.getSnapshot();
const sections = getNoteSections(snapshot);
const section = sections[sectionIndex];
const isLast = sectionIndex >= sections.length - 1;
const placed = new Set(pages.flatMap((page) => (page.notes ?? []).map((note) => note.noteId)));
const references = [...this._ctx.noteReferences?.values() ?? []].filter((reference) => {
if (reference.type !== 'endnote' || placed.has(reference.noteId)) {
return false;
}
if (isLast) {
return true;
}
return reference.properties.position === 'sectEnd' && !section?.suppressEndnotes &&
reference.referenceIndex <= (section?.startIndex ?? -1);
});
if (references.length === 0 || pages.length === 0) {
return;
}
this._ctx.footnoteLayout?.finish(pages, config);
let page = pages[pages.length - 1];
let top = getEndnoteFlowBottom(page);
let decoration = this._layoutDecoration(page, config, 'separator');
let needsSeparator = true;
for (const reference of references) {
const contentHeight = page.pageHeight - page.marginTop - page.marginBottom - (page.footnoteHeight ?? 0);
const separatorHeight = needsSeparator ? decoration?.height ?? 0 : 0;
const continuation = this._layoutDecoration(page, config, 'continuationSeparator');
const notice = this._layoutDecoration(page, config, 'continuationNotice');
const options = {
width: page.pageWidth - page.marginLeft - page.marginRight,
firstPageHeight: Math.max(1, contentHeight - top - separatorHeight),
continuationPageHeight: Math.max(1, page.pageHeight - page.marginTop - page.marginBottom - (continuation?.height ?? 0) - (notice?.height ?? 0)),
columnCount: 1,
};
let notePages = layoutNoteBody(this._ctx, reference, config, options);
if (notice && notePages.length > 1) {
notePages = layoutNoteBody(this._ctx, reference, config, { ...options, firstPageHeight: Math.max(1, options.firstPageHeight - notice.height) });
}
let continued = false;
for (let index = 0; index < notePages.length; index++) {
const fragment = notePages[index];
if (index > 0) {
page = createSkeletonPage(this._ctx, config, this._ctx.skeletonResourceReference, page.pageNumber + 1);
pages.push(page);
top = 0;
needsSeparator = true;
decoration = continued ? continuation : this._layoutDecoration(page, config, 'separator');
}
if (!hasNoteContent(fragment)) {
continue;
}
if (needsSeparator && decoration) {
page.noteDecorations ??= [];
page.noteDecorations.push({
noteType: 'endnote',
kind: continued ? 'continuationSeparator' : 'separator',
left: page.marginLeft,
top: page.marginTop + top,
page: decoration,
});
top += decoration.height;
}
needsSeparator = false;
page.notes ??= [];
page.notes.push({ noteType: 'endnote', noteId: reference.noteId, referenceIndex: reference.referenceIndex, continued, left: page.marginLeft, top: page.marginTop + top, page: fragment });
top += fragment.height;
continued = true;
if (notice && index < notePages.length - 1) {
page.noteDecorations ??= [];
page.noteDecorations.push({ noteType: 'endnote', kind: 'continuationNotice', left: page.marginLeft, top: page.marginTop + top, page: notice });
top += notice.height;
}
}
placed.add(reference.noteId);
}
}
private _layoutDecoration(
page: IDocumentSkeletonPage,
config: ISectionBreakConfig,
kind: 'separator' | 'continuationSeparator' | 'continuationNotice'
): IDocumentSkeletonPage | undefined {
const snapshot = this._ctx.dataModel.getSnapshot();
const body = snapshot.noteSettings?.endnote?.[kind] ?? (kind === 'continuationNotice' ? undefined : defaultSeparatorBody(kind === 'continuationSeparator'));
if (!body) {
return;
}
const model = new DocumentDataModel({ id: `endnote-${kind}`, body, documentStyle: snapshot.documentStyle, styles: snapshot.styles });
const viewModel = new DocumentViewModel(model);
try {
return layoutNoteSegment(this._ctx, viewModel, { noteId: model.getUnitId(), sectionId: config.sectionId }, config, {
width: page.pageWidth - page.marginLeft - page.marginRight,
firstPageHeight: Number.POSITIVE_INFINITY,
continuationPageHeight: Number.POSITIVE_INFINITY,
})[0];
} finally {
viewModel.dispose();
model.dispose();
}
}
}
function hasNoteContent(page: IDocumentSkeletonPage): boolean {
return page.sections.some((section) => section.columns.some((column) => column.lines.length > 0));
}
export function getEndnoteFlowBottom(page: IDocumentSkeletonPage): number {
const bodyBottom = page.sections.reduce((bottom, section) => Math.max(bottom, section.top, ...section.columns.map((column) => {
const line = column.lines[column.lines.length - 1];
return line ? section.top + line.top + line.lineHeight : section.top;
})), 0);
return Math.max(bodyBottom, ...page.notes?.filter((note) => note.noteType === 'endnote')
.map((note) => note.top - page.marginTop + note.page.height) ?? []);
}
@@ -0,0 +1,789 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { IDocumentBody } from '@univerjs/core';
import type {
IDocumentSkeletonGlyph,
IDocumentSkeletonNote,
IDocumentSkeletonNoteDecoration,
IDocumentSkeletonPage,
IDocumentSkeletonTable,
} from '../../../basics/i-document-skeleton-cached';
import type { IParagraphConfig, ISectionBreakConfig } from '../../../basics/interfaces';
import type { INoteReferenceLayout } from './note-numbering';
import type { ILayoutContext } from './tools';
import { ColumnSeparatorType, CustomRangeType, DocumentDataModel, GridType } from '@univerjs/core';
import { DocumentSkeletonPageType } from '../../../basics/i-document-skeleton-cached';
import { getDocumentCompatibilityPolicy } from '../document-compatibility';
import { DocumentViewModel } from '../view-model/document-view-model';
import { dealWithSection } from './block/section';
import { createSkeletonLetterGlyph } from './model/glyph';
import {
createSkeletonPage,
expandCellPageHeightForFlowTables,
expandCellPageHeightForInlineDrawings,
} from './model/page';
import { formatNoteNumber } from './note-numbering';
import {
clearFontCreateConfigCache,
getFontConfigFromLastGlyph,
getNullSkeleton,
updateBlockIndex,
updateInlineDrawingCoordsAndBorder,
} from './tools';
export interface INoteLayoutConstraints {
width: number;
firstPageHeight: number;
continuationPageHeight: number;
columnCount?: number;
firstColumn?: { index: number; top: number };
}
function createFootnoteColumns(config: ISectionBreakConfig, width: number, count: number) {
if (count <= 1) {
return [];
}
if (config.columnProperties?.length === count) {
return config.columnProperties;
}
const gap = Math.min(48, width / (count * 2));
const columnWidth = Math.max(1, (width - gap * (count - 1)) / count);
return Array.from({ length: count }, (_, index) => ({
width: columnWidth,
paddingEnd: index === count - 1 ? 0 : gap,
}));
}
function lastFootnoteColumn(fragments: IDocumentSkeletonNote[]) {
const page = fragments[fragments.length - 1]?.page;
const columns = page?.sections[page.sections.length - 1]?.columns ?? [];
for (let index = columns.length - 1; index >= 0; index--) {
const line = columns[index].lines[columns[index].lines.length - 1];
if (line) {
return { index, top: line.top + line.lineHeight };
}
}
return { index: 0, top: 0 };
}
/** Uses the body layout pipeline, including table row splitting and inline drawing metrics. */
export function layoutNoteBody(
parentContext: ILayoutContext,
reference: INoteReferenceLayout,
sectionConfig: ISectionBreakConfig,
constraints: INoteLayoutConstraints
): IDocumentSkeletonPage[] {
const viewModel = parentContext.viewModel.getNoteTreeMap().get(reference.noteId);
if (!viewModel) {
return [];
}
return layoutNoteSegment(parentContext, viewModel, reference, sectionConfig, constraints);
}
export function layoutNoteSegment(
parentContext: ILayoutContext,
viewModel: DocumentViewModel,
reference: Pick<INoteReferenceLayout, 'noteId' | 'sectionId'> & { label?: string },
sectionConfig: ISectionBreakConfig,
constraints: INoteLayoutConstraints
): IDocumentSkeletonPage[] {
const skeleton = getNullSkeleton();
const { skeHeaders, skeFooters, skeListLevel, drawingAnchor } = skeleton;
const dataModel = viewModel.getDataModel();
const config: ISectionBreakConfig = {
...sectionConfig,
documentCompatibilityPolicy: sectionConfig.documentCompatibilityPolicy ??
getDocumentCompatibilityPolicy(parentContext.dataModel.documentStyle.documentFlavor),
sectionId: reference.sectionId,
pageSize: { width: constraints.width, height: constraints.continuationPageHeight },
marginTop: 0,
marginBottom: 0,
marginLeft: 0,
marginRight: 0,
marginHeader: 0,
marginFooter: 0,
headerIds: {},
footerIds: {},
headerTreeMap: new Map(),
footerTreeMap: new Map(),
columnProperties: createFootnoteColumns(sectionConfig, constraints.width, constraints.columnCount ?? 1),
columnSeparatorType: ColumnSeparatorType.NONE,
gridType: GridType.DEFAULT,
drawings: dataModel.drawings,
lists: { ...sectionConfig.lists, ...dataModel.lists },
documentTextStyle: reference.label == null ? sectionConfig.documentTextStyle : { ...sectionConfig.documentTextStyle, fs: 10 },
};
const ctx: ILayoutContext = {
...parentContext,
viewModel,
dataModel,
skeleton,
skeletonResourceReference: { skeHeaders, skeFooters, skeListLevel, drawingAnchor },
noteReferences: undefined,
footnoteLayout: undefined,
endnoteLayout: undefined,
noteLabel: reference.label,
noteReferenceTextStyle: parentContext.dataModel.getSnapshot().notes?.[reference.noteId]?.referenceTextStyle,
noteSegmentId: reference.noteId,
footnoteFirstColumn: constraints.firstColumn,
deferSlicedTableLayout: undefined,
layoutStartPointer: { [reference.noteId]: null },
isDirty: false,
floatObjectsCache: new Map(),
paragraphConfigCache: new Map(),
sectionBreakConfigCache: new Map(),
paragraphsOpenNewPage: new Set(),
};
const firstPage = createSkeletonPage(ctx, {
...config,
pageSize: { width: constraints.width, height: Math.max(1, constraints.firstPageHeight) },
}, ctx.skeletonResourceReference);
firstPage.type = DocumentSkeletonPageType.NOTE;
firstPage.segmentId = reference.noteId;
for (let index = 0; index < (constraints.firstColumn?.index ?? 0); index++) {
firstPage.sections[0].columns[index].isFull = true;
}
try {
const pages = dealWithSection(ctx, viewModel, viewModel.getChildren()[0], firstPage, config, null).pages;
for (const page of pages) {
page.type = DocumentSkeletonPageType.NOTE;
page.segmentId = reference.noteId;
}
updateBlockIndex(pages, -1, config.documentCompatibilityPolicy);
updateInlineDrawingCoordsAndBorder(ctx, pages);
expandCellPageHeightForInlineDrawings(pages);
expandCellPageHeightForFlowTables(pages);
return pages;
} finally {
// Text run offsets are local to a segment. Never reuse a note's font cache for body text.
clearFontCreateConfigCache();
}
}
interface IPageFootnoteLayout {
fragments: IDocumentSkeletonNote[];
following: IDocumentSkeletonNote[][];
referenceIds: string[];
height: number;
separator?: IDocumentSkeletonPage;
notice?: IDocumentSkeletonPage;
}
type FootnoteDecorationKind = IDocumentSkeletonNoteDecoration['kind'];
export function defaultSeparatorBody(continued: boolean): IDocumentBody {
return {
dataStream: '\uFFFC\r\n',
paragraphs: [{ paragraphId: 'separator', startIndex: 1, paragraphStyle: { spaceAbove: { v: 0 }, spaceBelow: { v: 0 } } }],
sectionBreaks: [{ sectionId: 'separator', startIndex: 2 }],
customRanges: [{
rangeId: 'separator',
rangeType: CustomRangeType.FIELD,
startIndex: 0,
endIndex: 0,
wholeEntity: true,
properties: { fieldType: continued ? 'FOOTNOTE_CONTINUATION_SEPARATOR' : 'FOOTNOTE_SEPARATOR' },
}],
};
}
function footnoteContentHeight(fragments: IDocumentSkeletonNote[]): number {
return fragments.reduce((height, fragment) => Math.max(height, fragment.page.height), 0);
}
function collectPageReferenceGlyphs(page: IDocumentSkeletonPage): IDocumentSkeletonGlyph[] {
const result: IDocumentSkeletonGlyph[] = [];
for (const section of page.sections) {
for (const column of section.columns) {
for (const line of column.lines) {
for (const divide of line.divides) {
result.push(...divide.glyphGroup.filter((glyph) => glyph.noteId));
}
}
}
}
for (const table of page.skeTables.values()) {
result.push(...table.rows.flatMap((row) => row.cells.flatMap(collectPageReferenceGlyphs)));
}
for (const group of page.skeColumnGroups.values()) {
result.push(...group.columns.flatMap((column) => collectPageReferenceGlyphs(column.page)));
}
return result;
}
function collectPageReferenceIds(page: IDocumentSkeletonPage): string[] {
return [...new Set(collectPageReferenceGlyphs(page).map((glyph) => glyph.noteId!))];
}
function collectCellReferenceLines(page: IDocumentSkeletonPage, offset = 0): { top: number; bottom: number; glyphs: IDocumentSkeletonGlyph[] }[] {
const lines = page.sections.flatMap((section) => section.columns.flatMap((column) => column.lines.flatMap((line) => {
const glyphs = line.divides.flatMap((divide) => divide.glyphGroup.filter((glyph) => glyph.noteId));
const top = offset + page.marginTop + section.top + line.top;
return glyphs.length > 0 ? [{ top, bottom: top + line.lineHeight + page.marginBottom, glyphs }] : [];
})));
for (const table of page.skeTables.values()) {
for (const row of table.rows) {
lines.push(...row.cells.flatMap((cell) => collectCellReferenceLines(cell, offset + page.marginTop + table.top + row.top)));
}
}
return lines;
}
/** Per-layout state, shared by synchronous and sliced body pagination. */
export class DocumentFootnoteLayout {
private readonly _pages = new WeakMap<IDocumentSkeletonPage, IPageFootnoteLayout>();
private readonly _inherited = new WeakMap<IDocumentSkeletonPage, IPageFootnoteLayout>();
private readonly _references = new Map<string, INoteReferenceLayout>();
private readonly _referencePageBreaks = new Map<number, Map<number, number>>();
private readonly _decorations = new Map<string, IDocumentSkeletonPage | undefined>();
constructor(private readonly _ctx: ILayoutContext) {
for (const reference of _ctx.noteReferences?.values() ?? []) {
if (reference.type === 'footnote') {
this._references.set(reference.noteId, reference);
}
}
}
updateReferenceGlyphs(
page: IDocumentSkeletonPage,
glyphs: IDocumentSkeletonGlyph[],
config: ISectionBreakConfig,
paragraph: IParagraphConfig
): void {
if (!glyphs.some((glyph) => glyph.noteId) || page.type !== DocumentSkeletonPageType.BODY || page.segmentId !== '') {
return;
}
this._updateReferenceLabels(page, glyphs);
for (const glyph of glyphs) {
const reference = glyph.noteId ? this._references.get(glyph.noteId) : undefined;
if (!reference || reference.properties.restart !== 'eachPage' || reference.number == null) {
continue;
}
const label = reference.label;
if (glyph.content !== label) {
const fontConfig = getFontConfigFromLastGlyph(glyph, config, paragraph.paragraphStyle ?? {});
Object.assign(glyph, createSkeletonLetterGlyph(label, fontConfig), {
raw: '\uFFFC',
count: 1,
noteId: reference.noteId,
});
}
}
}
private _updateReferenceLabels(page: IDocumentSkeletonPage, incoming: IDocumentSkeletonGlyph[]): boolean {
const ids = [...new Set([...collectPageReferenceIds(page), ...incoming.map((glyph) => glyph.noteId!)])]
.filter((id) => this._references.has(id))
.sort((left, right) => this._references.get(left)!.referenceIndex - this._references.get(right)!.referenceIndex);
let precedingCount = 0;
let changed = false;
for (const id of ids) {
const reference = this._references.get(id)!;
if (reference.number == null) {
continue;
}
if (reference.properties.restart === 'eachPage') {
const number = (reference.properties.startNumber ?? 1) + precedingCount;
const label = formatNoteNumber(number, reference.properties.numberFormat);
changed ||= reference.label !== label;
reference.label = label;
}
precedingCount++;
}
if (changed) {
this._pages.delete(page);
}
return changed;
}
getTableBodyLimit(
page: IDocumentSkeletonPage,
currentPages: IDocumentSkeletonPage[],
config: ISectionBreakConfig,
table: IDocumentSkeletonTable
): number {
const references = table.rows.flatMap((row) => row.cells.flatMap(collectPageReferenceGlyphs));
return this.getBodyLimit(page, currentPages, config, table.top + table.height, references);
}
/** Measure table slices without publishing speculative notes on body pages. */
createTablePagination(page: IDocumentSkeletonPage, config: ISectionBreakConfig, availableHeight: number) {
if (page.type !== DocumentSkeletonPageType.BODY || page.segmentId !== '') {
return undefined;
}
let current = { ...page, skeTables: new Map(page.skeTables) };
const pages = [current];
this._inherited.set(current, this._getInherited(page, this._ctx.skeleton.pages, config));
let top = page.pageHeight - page.marginTop - page.marginBottom - availableHeight;
return {
renumberWholeRow: (table: IDocumentSkeletonTable, rows: IDocumentSkeletonTable['rows'], remainingHeight: number): boolean => {
if (!rows.some((row) => row.cells.some((cell) => collectPageReferenceGlyphs(cell)
.some((glyph) => this._references.get(glyph.noteId!)?.properties.restart === 'eachPage')))) {
return false;
}
let target = { ...current, skeTables: new Map(current.skeTables) };
const targetPages = [target];
this._inherited.set(target, this._getInherited(current, pages, config));
const accepted = this._pages.get(current);
if (accepted) {
this._pages.set(target, accepted);
}
let offset = top + table.height;
let preceding = table.rows.flatMap((row) => row.cells.flatMap(collectPageReferenceGlyphs));
let changed = false;
for (const row of rows) {
const glyphs = row.cells.flatMap(collectPageReferenceGlyphs);
let previousPlan = this._pages.get(target) ?? this._getInherited(target, targetPages, config);
this._updateReferenceLabels(target, [...preceding, ...glyphs]);
let limit = this.getBodyLimit(target, targetPages, config, offset + row.height, [...preceding, ...glyphs]);
const tolerance = (config.documentCompatibilityPolicy ?? getDocumentCompatibilityPolicy()).table.rowOverflowTolerance;
const requiresNewPage = row !== rows[0] || remainingHeight <= 0 || row.height > remainingHeight + tolerance;
if (requiresNewPage || offset + row.height > limit + 0.01) {
do {
this._pages.set(target, previousPlan);
target = createSkeletonPage(this._ctx, config, this._ctx.skeletonResourceReference, target.pageNumber + 1);
targetPages.push(target);
offset = 0;
preceding = [];
previousPlan = this._getInherited(target, targetPages, config);
this._updateReferenceLabels(target, glyphs);
limit = this.getBodyLimit(target, targetPages, config, row.height, glyphs);
} while (row.height > limit + 0.01 && this._getInherited(target, targetPages, config).fragments.length > 0);
}
changed ||= glyphs.some((glyph) => glyph.content !== this._references.get(glyph.noteId!)?.label);
offset += row.height;
preceding.push(...glyphs);
}
return changed;
},
measureRow: (table: IDocumentSkeletonTable, rows: IDocumentSkeletonTable['rows'], previousHeights?: readonly number[]): number[] | undefined => {
let measurePage = { ...current, skeTables: new Map(current.skeTables) };
const measurePages = [measurePage];
this._inherited.set(measurePage, this._getInherited(current, pages, config));
const accepted = this._pages.get(current);
if (accepted) {
this._pages.set(measurePage, accepted);
}
const heights = [...(previousHeights ?? [])];
let changed = false;
for (let index = 0; index < rows.length; index++) {
const row = rows[index];
const offset = index === 0 ? top + table.height : 0;
const contentHeight = measurePage.pageHeight - measurePage.marginTop - measurePage.marginBottom;
const available = heights[index] ?? contentHeight - offset;
const lines = row.cells
.flatMap((cell) => collectCellReferenceLines(cell))
.filter((line) => line.bottom <= available)
.sort((left, right) => left.bottom - right.bottom);
const incoming = lines.flatMap((line) => line.glyphs);
if (index === 0) {
incoming.push(...table.rows.flatMap((item) => item.cells.flatMap(collectPageReferenceGlyphs)));
}
// A cell is shaped before its physical page is known. Rebuild the
// row when its page-local marker changes, including marker width.
changed = this._updateReferenceLabels(measurePage, incoming) || changed;
const lastReference = lines[lines.length - 1];
const minimumBottom = lastReference?.bottom ?? Math.min(row.height, available);
const limit = this.getBodyLimit(measurePage, measurePages, config, offset + minimumBottom, incoming) - offset;
const deferredIds = new Set(this._pages.get(measurePage)?.following.flatMap((fragments) => fragments.filter((fragment) => !fragment.continued).map((fragment) => fragment.noteId)));
const deferredLine = lines.find((line) => line.top > 0 && line.glyphs.some((glyph) => deferredIds.has(glyph.noteId!)));
// A continuing note can postpone later notes. Split before a later
// reference line so its first note fragment follows it to the next page.
const capacity = Math.max(1, Math.min(
limit < minimumBottom && lastReference ? lastReference.top : limit,
deferredLine?.top ?? Number.POSITIVE_INFINITY
));
if (row.height > capacity + 0.01 && capacity + 0.01 < available) {
heights[index] = capacity;
changed = true;
} else {
heights[index] = available;
}
measurePage = createSkeletonPage(this._ctx, config, this._ctx.skeletonResourceReference, measurePage.pageNumber + 1);
measurePages.push(measurePage);
}
return changed ? heights : undefined;
},
append: (table: IDocumentSkeletonTable, row: IDocumentSkeletonTable['rows'][number]): number | undefined => {
const previous = this._pages.get(current);
const incoming = [...table.rows, row].flatMap((item) => item.cells.flatMap(collectPageReferenceGlyphs));
const bottom = top + table.height + row.height;
const limit = this.getBodyLimit(current, pages, config, bottom, incoming);
if (bottom <= limit + 0.01) {
return Math.max(0, limit - bottom);
}
if (previous) {
this._pages.set(current, previous);
} else {
this._pages.set(current, this._getInherited(current, pages, config));
}
return undefined;
},
hasContinuation: () => this._getInherited(current, pages, config).fragments.length > 0,
nextPage: () => {
current = createSkeletonPage(this._ctx, config, this._ctx.skeletonResourceReference, current.pageNumber + 1);
pages.push(current);
top = 0;
},
};
}
getBodyLimit(
page: IDocumentSkeletonPage,
currentPages: IDocumentSkeletonPage[],
config: ISectionBreakConfig,
bodyBottom: number,
incoming: IDocumentSkeletonGlyph[] = []
): number {
const contentHeight = page.pageHeight - page.marginTop - page.marginBottom;
if (page.type !== DocumentSkeletonPageType.BODY || page.segmentId !== '' || !Number.isFinite(contentHeight)) {
return contentHeight;
}
const inherited = this._getInherited(page, currentPages, config);
const ids = new Set(collectPageReferenceIds(page));
for (const glyph of incoming) {
if (glyph.noteId) {
ids.add(glyph.noteId);
}
}
const referenceIds = [...ids].filter((id) => this._references.has(id)).sort((left, right) =>
(this._references.get(left)?.referenceIndex ?? 0) - (this._references.get(right)?.referenceIndex ?? 0));
const previous = this._pages.get(page);
if (previous && previous.referenceIds.length === referenceIds.length &&
previous.referenceIds.every((id, index) => id === referenceIds[index])) {
this._position(page, previous, bodyBottom);
return contentHeight - previous.height;
}
const firstReference = this._references.get(inherited.fragments[0]?.noteId ?? referenceIds[0]);
const requestedCount = firstReference?.properties.columnCount ?? 0;
const columnCount = requestedCount === 0 ? Math.max(1, config.columnProperties?.length ?? 1) : requestedCount;
const separator = this._layoutDecoration(inherited.fragments.some((fragment) => fragment.continued) ? 'continuationSeparator' : 'separator', page, config);
const available = Math.max(1, contentHeight - bodyBottom - (separator?.height ?? 0));
let plan = this._layoutPageNotes(page, config, referenceIds, inherited, available, columnCount, separator);
if (!plan) {
return -1;
}
// Balance all notes on this page together; a continuing note already uses
// the full available height and must retain its continuation boundary.
if (columnCount > 1 && plan.following.length === 0 && inherited.fragments.length === 0 && plan.fragments.length > 0) {
let low = 1;
let high = Math.max(1, footnoteContentHeight(plan.fragments));
for (let attempt = 0; attempt < 12 && high - low > 0.25; attempt++) {
const middle = (low + high) / 2;
const candidate = this._layoutPageNotes(page, config, referenceIds, inherited, middle, columnCount, separator);
if (candidate && candidate.following.length === 0) {
plan = candidate;
high = middle;
} else {
low = middle;
}
}
}
this._pages.set(page, plan);
this._position(page, plan, bodyBottom);
return contentHeight - plan.height;
}
finish(pages: IDocumentSkeletonPage[], config: ISectionBreakConfig): void {
let previous = pages[pages.length - 1];
if (!previous) {
return;
}
while (this._pages.get(previous)?.following.length) {
const page = createSkeletonPage(this._ctx, config, this._ctx.skeletonResourceReference, previous.pageNumber + 1);
pages.push(page);
const inherited = this._getInherited(page, pages, config);
this._pages.set(page, inherited);
this._position(page, inherited, 0);
previous = page;
}
}
getReferencePageBreaks(paragraphIndex: number): readonly number[] | undefined {
const boundaries = this._referencePageBreaks.get(paragraphIndex);
return boundaries ? [...boundaries.values()] : undefined;
}
/** Re-shape moved text while retaining the page boundary chosen by keep/widow control. */
queueReferenceRelayout(pages: IDocumentSkeletonPage[]): void {
let anchor = Number.POSITIVE_INFINITY;
for (const page of pages) {
if (page.type !== DocumentSkeletonPageType.BODY || page.segmentId !== '') {
continue;
}
this._updateReferenceLabels(page, []);
for (const section of page.sections) {
for (const column of section.columns) {
const offsets = new Map<number, number>();
for (const line of column.lines) {
let offset = offsets.get(line.paragraphIndex) ?? 0;
for (const glyph of line.divides.flatMap((divide) => divide.glyphGroup)) {
const reference = this._references.get(glyph.noteId ?? '');
if (reference?.properties.restart === 'eachPage' && glyph.content !== reference.label) {
const boundaries = this._referencePageBreaks.get(line.paragraphIndex) ?? new Map<number, number>();
boundaries.set(page.pageNumber, reference.referenceIndex - offset);
this._referencePageBreaks.set(line.paragraphIndex, boundaries);
anchor = Math.min(anchor, line.paragraphIndex);
}
offset += glyph.count;
}
offsets.set(line.paragraphIndex, offset);
}
}
}
}
if (!Number.isFinite(anchor)) {
return;
}
// The retained physical-page boundary also covers keep-next predecessors.
// A second whole-paragraph break would separate them again during the retry.
for (const paragraph of this._ctx.paragraphsOpenNewPage) {
if (paragraph >= anchor) {
this._ctx.paragraphsOpenNewPage.delete(paragraph);
}
}
this._ctx.isDirty = true;
this._ctx.layoutStartPointer[''] = Math.min(anchor, this._ctx.layoutStartPointer[''] ?? Number.POSITIVE_INFINITY);
}
/** Rebuild reservations after keep-lines or widow control relocates shaped lines. */
reconcilePages(pages: IDocumentSkeletonPage[], config: ISectionBreakConfig): boolean {
let fits = true;
for (const page of pages) {
this._inherited.delete(page);
this._pages.delete(page);
this._updateReferenceLabels(page, []);
const bodyBottom = page.sections.reduce((bottom, section) => Math.max(
bottom,
...section.columns.map((column) => {
const line = column.lines[column.lines.length - 1];
return line ? section.top + line.top + line.lineHeight : 0;
})
), 0);
if (bodyBottom > this.getBodyLimit(page, pages, config, bodyBottom) + 2) {
fits = false;
}
}
return fits;
}
private _layoutPageNotes(
page: IDocumentSkeletonPage,
config: ISectionBreakConfig,
referenceIds: string[],
inherited: IPageFootnoteLayout,
available: number,
columnCount: number,
separator: IDocumentSkeletonPage | undefined,
reserveNotice = false
): IPageFootnoteLayout | undefined {
const plan: IPageFootnoteLayout = {
fragments: [...inherited.fragments],
following: inherited.following.map((fragments) => [...fragments]),
referenceIds,
height: inherited.height,
separator,
};
const width = page.pageWidth - page.marginLeft - page.marginRight;
const continuationSeparator = this._layoutDecoration('continuationSeparator', page, config);
const notice = this._layoutDecoration('continuationNotice', page, config);
const continuationHeight = page.pageHeight - page.marginTop - page.marginBottom -
(continuationSeparator?.height ?? 0) - (notice?.height ?? 0);
for (const id of referenceIds) {
const reference = this._references.get(id);
if (!reference) {
continue;
}
const delayed = plan.following.length > 0;
const target = delayed ? plan.following[plan.following.length - 1] : plan.fragments;
const notePages = layoutNoteBody(this._ctx, reference, config, {
width,
firstPageHeight: delayed ? continuationHeight : Math.max(1, available - (reserveNotice ? notice?.height ?? 0 : 0)),
continuationPageHeight: Math.max(1, continuationHeight),
columnCount,
firstColumn: lastFootnoteColumn(target),
});
const first = notePages[0];
if (!first) {
continue;
}
if (first.sections.every((section) => section.columns.every((column) => column.lines.length === 0))) {
// Keep a new reference with the first line of its note. Otherwise
// a later paragraph retry can inherit a note whose reference moved.
if (!delayed && (plan.fragments.length > 0 ||
page.sections.some((section) => section.columns.some((column) => column.lines.length > 0)))) {
return undefined;
}
this._appendFollowing(plan.following, notePages.slice(1), reference, false);
continue;
}
if (delayed) {
target.push({ noteId: id, referenceIndex: reference.referenceIndex, continued: false, left: page.marginLeft, top: 0, page: first });
this._appendFollowing(plan.following, notePages.slice(1), reference, true);
continue;
}
if (first.height > available + 2) {
if (page.sections.some((section) => section.columns.some((column) => column.lines.length > 0)) ||
plan.fragments.length > 0) {
return undefined;
}
this._appendFollowing(plan.following, notePages, reference, false);
continue;
}
plan.fragments.push({
noteId: id,
referenceIndex: reference.referenceIndex,
continued: false,
left: page.marginLeft,
top: 0,
page: first,
});
this._appendFollowing(plan.following, notePages.slice(1), reference, true);
}
if (!reserveNotice && notice && plan.following.length > 0) {
return this._layoutPageNotes(page, config, referenceIds, inherited, available, columnCount, separator, true);
}
plan.notice = plan.following.length > 0 ? notice : undefined;
plan.height = plan.fragments.length === 0
? 0
: (separator?.height ?? 0) +
footnoteContentHeight(plan.fragments) + (plan.notice?.height ?? 0);
return plan;
}
private _layoutDecoration(kind: FootnoteDecorationKind, page: IDocumentSkeletonPage, config: ISectionBreakConfig): IDocumentSkeletonPage | undefined {
const width = page.pageWidth - page.marginLeft - page.marginRight;
const key = `${kind}:${config.sectionId}:${width}`;
if (this._decorations.has(key)) {
return this._decorations.get(key);
}
const snapshot = this._ctx.dataModel.getSnapshot();
const body = snapshot.noteSettings?.footnote?.[kind] ?? (kind === 'continuationNotice' ? undefined : defaultSeparatorBody(kind === 'continuationSeparator'));
if (!body) {
this._decorations.set(key, undefined);
return undefined;
}
const model = new DocumentDataModel({ id: `footnote-${kind}`, body, documentStyle: snapshot.documentStyle, styles: snapshot.styles });
const viewModel = new DocumentViewModel(model);
try {
const pages = layoutNoteSegment(this._ctx, viewModel, { noteId: model.getUnitId(), sectionId: config.sectionId }, config, {
width,
firstPageHeight: Number.POSITIVE_INFINITY,
continuationPageHeight: Number.POSITIVE_INFINITY,
});
const decoration = pages[0];
if (decoration) {
for (const section of decoration.sections) {
for (const column of section.columns) {
const lastLine = column.lines[column.lines.length - 1];
if (lastLine) {
decoration.height = Math.max(decoration.height, section.top + lastLine.top + lastLine.lineHeight + Math.max(0, lastLine.spaceBelowApply ?? 0));
}
}
}
}
this._decorations.set(key, decoration);
return decoration;
} finally {
viewModel.dispose();
model.dispose();
}
}
private _appendFollowing(
following: IDocumentSkeletonNote[][],
pages: IDocumentSkeletonPage[],
reference: INoteReferenceLayout,
continued: boolean
): void {
for (let index = 0; index < pages.length; index++) {
following.push([{
noteId: reference.noteId,
referenceIndex: reference.referenceIndex,
continued: continued || index > 0,
left: 0,
top: 0,
page: pages[index],
}]);
}
}
private _getInherited(page: IDocumentSkeletonPage, currentPages: IDocumentSkeletonPage[], config: ISectionBreakConfig): IPageFootnoteLayout {
const existing = this._inherited.get(page);
if (existing) {
return existing;
}
let pageIndex = currentPages.indexOf(page);
let previous = pageIndex > 0 ? currentPages[pageIndex - 1] : undefined;
if (!previous) {
const pages = this._ctx.skeleton.pages;
pageIndex = pages.indexOf(page);
previous = pageIndex > 0 ? pages[pageIndex - 1] : pages[pages.length - 1];
if (previous === page) {
previous = undefined;
}
}
const previousPlan = previous == null ? undefined : this._pages.get(previous);
const fragments = previousPlan?.following[0] ?? [];
const separator = fragments.length > 0
? this._layoutDecoration(fragments.some((fragment) => fragment.continued) ? 'continuationSeparator' : 'separator', page, config)
: undefined;
const notice = (previousPlan?.following.length ?? 0) > 1 ? this._layoutDecoration('continuationNotice', page, config) : undefined;
const plan: IPageFootnoteLayout = {
fragments: fragments.map((fragment) => ({ ...fragment, left: page.marginLeft })),
following: previousPlan?.following.slice(1) ?? [],
referenceIds: [],
height: fragments.length === 0
? 0
: (separator?.height ?? 0) + footnoteContentHeight(fragments) + (notice?.height ?? 0),
separator,
notice,
};
this._inherited.set(page, plan);
return plan;
}
private _position(page: IDocumentSkeletonPage, plan: IPageFootnoteLayout, bodyBottom: number): void {
if (plan.fragments.length === 0) {
page.notes = page.notes?.filter((note) => this._ctx.dataModel.getSnapshot().notes?.[note.noteId]?.type === 'endnote');
delete page.footnoteHeight;
page.noteDecorations = page.noteDecorations?.filter((decoration) => decoration.noteType === 'endnote');
return;
}
const firstReference = this._references.get(plan.fragments[0].noteId);
const separatorHeight = plan.separator?.height ?? 0;
const top = firstReference?.properties.position === 'beneathText'
? page.marginTop + bodyBottom + separatorHeight
: page.pageHeight - page.marginBottom - plan.height + separatorHeight;
const endnotes = page.notes?.filter((note) => this._ctx.dataModel.getSnapshot().notes?.[note.noteId]?.type === 'endnote') ?? [];
page.notes = [...endnotes, ...plan.fragments.map((fragment) => {
const positioned = { ...fragment, top, left: page.marginLeft };
return positioned;
})];
page.footnoteHeight = plan.height;
const decorations: IDocumentSkeletonNoteDecoration[] = [];
if (plan.separator) {
decorations.push({ kind: plan.fragments.some((fragment) => fragment.continued) ? 'continuationSeparator' : 'separator', left: page.marginLeft, top: top - separatorHeight, page: plan.separator });
}
if (plan.notice) {
decorations.push({ kind: 'continuationNotice', left: page.marginLeft, top: top + footnoteContentHeight(plan.fragments), page: plan.notice });
}
page.noteDecorations = [...page.noteDecorations?.filter((decoration) => decoration.noteType === 'endnote') ?? [], ...decorations];
}
}
@@ -14,6 +14,7 @@
* limitations under the License.
*/
import type { IDocumentSkeletonPage } from '../../../../../basics/i-document-skeleton-cached';
import { BooleanNumber, PositionedObjectLayoutType, TableTextWrapType, WrapTextType } from '@univerjs/core';
import { describe, expect, it } from 'vitest';
import { LineType } from '../../../../../basics/i-document-skeleton-cached';
@@ -43,6 +44,27 @@ function createTopBottomDrawing(top: number, height: number, angle = 0) {
}
describe('line model', () => {
it.each([
{ gap: 24, minimumWidth: 24, expectedTop: 130 },
{ gap: 25, minimumWidth: 24, expectedTop: 20 },
{ gap: 30, minimumWidth: 56, expectedTop: 130 },
{ gap: 80, minimumWidth: 56, expectedTop: 20 },
])('clears floating tables when side gaps cannot accommodate text ($gap, $minimumWidth)', ({ gap, minimumWidth, expectedTop }) => {
const page = {
skeDrawings: new Map(),
skeTables: new Map([['table', {
left: gap,
top: 10,
width: 300 - gap * 2,
height: 120,
tableSource: { textWrap: TableTextWrapType.WRAP, dist: {} },
}]]),
} as unknown as IDocumentSkeletonPage;
expect(calculateLineTopByDrawings(16, 20, page, null, null, 0, 300, 0, minimumWidth)).toBe(expectedTop);
expect(calculateLineTopByDrawings(16, 140, page, null, null, 0, 300, 0, minimumWidth)).toBe(140);
expect(calculateLineTopByDrawings(16, 20, page, null, null, 400, 300, 0, minimumWidth)).toBe(20);
});
it('creates line skeleton and divides with drawing/table layout data', () => {
const page = {
marginTop: 8,
@@ -256,7 +256,7 @@ export function _createSkeletonWordOrLetter(
let xOffset = 0;
const documentCompatibilityPolicy = config.documentCompatibilityPolicy ?? getDocumentCompatibilityPolicy();
bBox = FontCache.getTextSize(content, fontStyle);
bBox = FontCache.getTextSize(content, fontStyle, isTraditionalDocumentCompatibility(documentCompatibilityPolicy));
bBox = applyFontMetricCompatibility(
content,
fontStyle,
@@ -15,7 +15,6 @@
*/
import type { IDistFromText, Nullable } from '@univerjs/core';
import type { IParagraphConfig } from '../../../../basics';
import type {
IDocumentSkeletonDivide,
IDocumentSkeletonDrawing,
@@ -25,11 +24,17 @@ import type {
IDocumentSkeletonTable,
LineType,
} from '../../../../basics/i-document-skeleton-cached';
import type { IParagraphConfig } from '../../../../basics/interfaces';
import type { IFloatObject } from '../tools';
import { PositionedObjectLayoutType, TableTextWrapType, WrapTextType } from '@univerjs/core';
import { Path2 } from '../../../../basics/path2';
import { Transform } from '../../../../basics/transform';
import { Vector2 } from '../../../../basics/vector2';
import { isTraditionalDocumentCompatibility } from '../../document-compatibility';
// Word reserves at least 18pt for text beside a floating table. Narrower gaps
// move the line below the table, including empty paragraphs.
export const TRADITIONAL_TABLE_WRAP_MIN_WIDTH = 24;
interface IDrawingsSplit {
left: number;
@@ -131,7 +136,10 @@ export function createSkeletonLine(
affectSkeDrawings,
headersDrawings,
footersDrawings,
wrapTypeTables
wrapTypeTables,
paragraphConfig.documentCompatibilityPolicy && isTraditionalDocumentCompatibility(paragraphConfig.documentCompatibilityPolicy)
? TRADITIONAL_TABLE_WRAP_MIN_WIDTH
: 0
);
for (const divide of lineSke.divides) {
@@ -149,7 +157,8 @@ export function calculateLineTopByDrawings(
footerPage: Nullable<IDocumentSkeletonPage>,
columnLeft: number = 0,
columnWidth: number = 0,
sectionTop: number = 0
sectionTop: number = 0,
minimumTableWrapWidth: number = 0
) {
const absoluteLineTop = sectionTop + lineTop;
let maxTop = absoluteLineTop;
@@ -208,9 +217,50 @@ export function calculateLineTopByDrawings(
}
});
// A later table can occupy the line reached after clearing an earlier one.
for (let pass = 0; pass < page.skeTables.size; pass++) {
const previousTop = maxTop;
for (const table of page.skeTables.values()) {
if (table.tableSource.textWrap !== TableTextWrapType.WRAP || columnWidth <= 0) {
continue;
}
const split = _getTableWrapSplit(table, maxTop, lineHeight, columnLeft, columnWidth, minimumTableWrapWidth);
if (split && split.left <= 0 && split.left + split.width >= columnWidth) {
maxTop = Math.max(maxTop, table.top + table.height + (table.tableSource.dist?.distB ?? 0));
}
}
if (maxTop === previousTop) {
break;
}
}
return maxTop - sectionTop;
}
function _getTableWrapSplit(
table: IDocumentSkeletonTable,
lineTop: number,
lineHeight: number,
columnLeft: number,
columnWidth: number,
minimumWidth: number
) {
const { left, top, width, height, tableSource } = table;
const split = __getSplitWidthNoAngle(top, height, left - columnLeft, width, lineTop, lineHeight, columnWidth, tableSource.dist ?? {});
if (!split || split.left >= columnWidth || split.left + split.width <= 0) {
return;
}
let right = split.left + split.width;
if (split.left <= minimumWidth) {
split.left = 0;
}
if (columnWidth - right <= minimumWidth) {
right = columnWidth;
}
split.width = right - split.left;
return split;
}
function _getLineTopWithFullColumnWrap(
drawing: IDocumentSkeletonDrawing,
lineHeight: number,
@@ -363,7 +413,8 @@ function _calculateDividesByDrawings(
paragraphNonInlineSkeDrawings?: Map<string, IDocumentSkeletonDrawing>,
headersDrawings?: Map<string, IDocumentSkeletonDrawing>,
footersDrawings?: Map<string, IDocumentSkeletonDrawing>,
wrapTypeTables?: Map<string, IDocumentSkeletonTable>
wrapTypeTables?: Map<string, IDocumentSkeletonTable>,
minimumTableWrapWidth: number = 0
): IDocumentSkeletonDivide[] {
const drawingsMix: IDrawingsSplit[] = []; // Mixed text and graphics case
// Insert indent placeholder
@@ -410,9 +461,7 @@ function _calculateDividesByDrawings(
if (wrapTypeTables && wrapTypeTables.size > 0) {
wrapTypeTables.forEach((table) => {
const { left, top, width, height, tableSource } = table;
const { dist } = tableSource;
const split = __getSplitWidthNoAngle(top, height, left - columnLeft, width, lineTop, lineHeight, columnWidth, dist);
const split = _getTableWrapSplit(table, lineTop, lineHeight, columnLeft, columnWidth, minimumTableWrapWidth);
if (split) {
drawingsMix.push(split);
@@ -24,11 +24,23 @@ 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, GridType, PageOrientType, PositionedObjectLayoutType, SpacingRule, TableTextWrapType } from '@univerjs/core';
import {
BooleanNumber,
GridType,
PageOrientType,
PositionedObjectLayoutType,
SpacingRule,
TableTextWrapType,
} from '@univerjs/core';
import { BreakType, DocumentSkeletonPageType } from '../../../../basics/i-document-skeleton-cached';
import { getDocumentCompatibilityPolicy, isTraditionalDocumentCompatibility } from '../../document-compatibility';
import { dealWithSection } from '../block/section';
import { reachesNextDocumentGridLine, resetContext, updateBlockIndex, updateInlineDrawingCoordsAndBorder } from '../tools';
import {
reachesNextDocumentGridLine,
resetContext,
updateBlockIndex,
updateInlineDrawingCoordsAndBorder,
} from '../tools';
import { createSkeletonSection } from './section';
function getHeaderFooterMaxHeight(pageHeight: number) {
@@ -44,7 +56,14 @@ export function createSkeletonPage(
pageNumber = 1,
breakType = BreakType.SECTION
): IDocumentSkeletonPage {
const page: IDocumentSkeletonPage = _getNullPage();
let page: IDocumentSkeletonPage;
if (sectionBreakConfig.cellTableId) {
page = _getNullPage(DocumentSkeletonPageType.CELL, sectionBreakConfig.cellTableId);
} else if (ctx.noteSegmentId) {
page = _getNullPage(DocumentSkeletonPageType.NOTE, ctx.noteSegmentId);
} else {
page = _getNullPage();
}
const {
sectionId,
@@ -70,7 +89,8 @@ export function createSkeletonPage(
const { skeHeaders, skeFooters } = skeletonResourceReference;
const { width: pageWidth = Number.POSITIVE_INFINITY, height: pageHeight = Number.POSITIVE_INFINITY } = pageSize;
const { width: pageWidth = Number.POSITIVE_INFINITY, height: configuredHeight = Number.POSITIVE_INFINITY } = pageSize;
const pageHeight = sectionBreakConfig.cellPageHeights?.[pageNumber - 1] ?? configuredHeight;
page.pageNumber = pageNumber;
page.sectionId = sectionId;
@@ -310,7 +330,8 @@ export function createNullCellPage(
availableHeight: number = Number.POSITIVE_INFINITY,
maxCellPageHeight: number = Number.POSITIVE_INFINITY,
inheritDocumentLinePitch = true,
enableDocumentTableLineGrid = true
enableDocumentTableLineGrid = true,
cellPageHeights?: readonly number[]
) {
const {
sectionId,
@@ -365,6 +386,8 @@ export function createNullCellPage(
const cellSectionBreakConfig: ISectionBreakConfig = {
sectionId,
cellTableId: tableId,
cellPageHeights,
lists,
footerTreeMap,
headerTreeMap,
@@ -527,7 +550,8 @@ export function createSkeletonCellPages(
row: number,
col: number,
availableHeight: number = Number.POSITIVE_INFINITY,
maxCellPageHeight: number = Number.POSITIVE_INFINITY
maxCellPageHeight: number = Number.POSITIVE_INFINITY,
cellPageHeights?: readonly number[]
) {
// Table cell only has one section.
const sectionNode = cellNode.children[0];
@@ -547,7 +571,8 @@ export function createSkeletonCellPages(
availableHeight,
maxCellPageHeight,
inheritDocumentLinePitch,
enableDocumentTableLineGrid
enableDocumentTableLineGrid,
cellPageHeights
);
const segmentId = tableConfig.tableId;
@@ -628,7 +653,8 @@ export function startSkeletonCellPagesBuild(
row: number,
col: number,
availableHeight: number = Number.POSITIVE_INFINITY,
maxCellPageHeight: number = Number.POSITIVE_INFINITY
maxCellPageHeight: number = Number.POSITIVE_INFINITY,
cellPageHeights?: readonly number[]
): ICellSkeletonBuildState {
const sectionNode = cellNode.children[0];
const { enableDocumentTableLineGrid, inheritDocumentLinePitch } = getCellLineGridOptions(
@@ -645,7 +671,8 @@ export function startSkeletonCellPagesBuild(
availableHeight,
maxCellPageHeight,
inheritDocumentLinePitch,
enableDocumentTableLineGrid
enableDocumentTableLineGrid,
cellPageHeights
);
page.type = DocumentSkeletonPageType.CELL;
page.segmentId = tableConfig.tableId;
@@ -0,0 +1,146 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { DocumentNoteType, IDocumentData, IFootnoteProperties, ISectionBreak } from '@univerjs/core';
import { CustomRangeType, ListGlyphType } from '@univerjs/core';
import { getBulletOrderedSymbol } from './block/paragraph/bullet-ruler';
const NUMBER_FORMATS: Record<string, ListGlyphType> = {
decimal: ListGlyphType.DECIMAL,
decimalZero: ListGlyphType.DECIMAL_ZERO,
upperLetter: ListGlyphType.UPPER_LETTER,
lowerLetter: ListGlyphType.LOWER_LETTER,
upperRoman: ListGlyphType.UPPER_ROMAN,
lowerRoman: ListGlyphType.LOWER_ROMAN,
chineseCounting: ListGlyphType.CHINESE_COUNTING,
};
export interface INoteLayoutProperties extends Omit<IFootnoteProperties, 'position'> {
position?: 'pageBottom' | 'beneathText' | 'docEnd' | 'sectEnd';
}
export interface INoteReferenceLayout {
noteId: string;
type: DocumentNoteType;
referenceIndex: number;
sectionId: string;
label: string;
number?: number;
properties: INoteLayoutProperties;
}
export function formatNoteNumber(number: number, format = 'decimal'): string {
if (format === 'none') {
return '';
}
if (format === 'chicago') {
const symbols = ['*', '†', '‡', '§', '‖', '¶'];
const index = Math.max(0, number - 1);
return symbols[index % symbols.length].repeat(Math.floor(index / symbols.length) + 1);
}
if (format === 'decimalEnclosedCircle') {
if (number === 0) {
return '⓪';
}
if (number >= 1 && number <= 20) {
return String.fromCodePoint(0x2460 + number - 1);
}
if (number >= 21 && number <= 35) {
return String.fromCodePoint(0x3251 + number - 21);
}
if (number >= 36 && number <= 50) {
return String.fromCodePoint(0x32B1 + number - 36);
}
}
if (format === 'ideographEnclosedCircle' && number >= 1 && number <= 10) {
return String.fromCodePoint(0x3220 + number - 1);
}
if (format === 'ideographTraditional' || format === 'ideographZodiac') {
const alphabet = format === 'ideographTraditional' ? '甲乙丙丁戊己庚辛壬癸' : '子丑寅卯辰巳午未申酉戌亥';
if (number >= 1 && number <= alphabet.length) {
return alphabet[number - 1];
}
}
if (format === 'chineseLegalSimplified') {
const standard = getBulletOrderedSymbol(0, number, ListGlyphType.CHINESE_COUNTING);
const digits = '零一二三四五六七八九十百千';
const legal = '零壹贰叁肆伍陆柒捌玖拾佰仟';
const result = standard.replace(/[零一二三四五六七八九十百千]/g, (digit) => legal[digits.indexOf(digit)]);
return result.startsWith('拾') ? `壹${result}` : result;
}
if (format === 'decimalFullWidth' || format === 'decimalFullWidth2') {
return String(number).replace(/\d/g, (digit) => String.fromCharCode(0xFF10 + Number(digit)));
}
if (format === 'ideographDigital' || format === 'taiwaneseDigital') {
return String(number).replace(/\d/g, (digit) => '〇一二三四五六七八九'[Number(digit)]);
}
return getBulletOrderedSymbol(0, number, NUMBER_FORMATS[format] ?? ListGlyphType.DECIMAL);
}
/** Page keys are physical page indices, independent of the printed page-number format. */
/** Cell section sentinels cannot own document-level note numbering or placement. */
export function getNoteSections(snapshot: Pick<IDocumentData, 'body'>): ISectionBreak[] {
const sections = snapshot.body?.sectionBreaks ?? [];
const tables = [...snapshot.body?.tables ?? []].sort((a, b) => a.startIndex - b.startIndex);
let tableIndex = 0;
let tableEnd = -1;
return sections.filter((section) => {
while (tableIndex < tables.length && tables[tableIndex].startIndex <= section.startIndex) {
tableEnd = Math.max(tableEnd, tables[tableIndex++].endIndex);
}
return section.startIndex > tableEnd;
});
}
export function resolveNoteReferences(
snapshot: Pick<IDocumentData, 'body' | 'notes' | 'noteSettings'>,
referencePages: ReadonlyMap<string, number> = new Map()
): Map<number, INoteReferenceLayout> {
const references = snapshot.body?.customRanges?.filter((range) => (range.rangeType === CustomRangeType.FOOTNOTE || range.rangeType === CustomRangeType.ENDNOTE)) ?? [];
const sections = getNoteSections(snapshot);
const result = new Map<number, INoteReferenceLayout>();
let sectionIndex = 0;
const counters = new Map<DocumentNoteType, { sectionId: string; page?: number; nextNumber: number }>();
for (const reference of references) {
const noteId = reference.properties?.noteId;
if (typeof noteId !== 'string' || !snapshot.notes?.[noteId]) {
continue;
}
while (sectionIndex < sections.length - 1 && sections[sectionIndex].startIndex < reference.startIndex) {
sectionIndex++;
}
const section = sections[sectionIndex];
const note = snapshot.notes[noteId];
const type = note.type;
const properties: INoteLayoutProperties = { ...snapshot.noteSettings?.[type], ...section?.noteProperties?.[type] };
const sectionId = section?.sectionId ?? '';
const page = referencePages.get(noteId);
const previous = counters.get(type);
let nextNumber = previous?.nextNumber;
if (nextNumber == null || (properties.restart === 'eachSect' && previous?.sectionId !== sectionId) ||
(properties.restart === 'eachPage' && page != null && previous?.page !== page)) {
nextNumber = properties.startNumber ?? 1;
}
const number = note.customMark == null ? nextNumber : undefined;
const label = note.customMark ?? formatNoteNumber(nextNumber, properties.numberFormat ?? (type === 'endnote' ? 'lowerRoman' : 'decimal'));
result.set(reference.startIndex, { noteId, type, referenceIndex: reference.startIndex, sectionId, properties, number, label });
if (number != null) {
nextNumber++;
}
counters.set(type, { sectionId, page, nextNumber });
}
return result;
}
@@ -19,6 +19,7 @@ import { FontCache, invalidateDocumentFontMetrics } from '../font-cache';
describe('font cache', () => {
beforeEach(() => {
invalidateDocumentFontMetrics(() => true);
(FontCache as any)._globalFontMeasureCache = new Map();
(FontCache as any)._fontDataMap = new Map();
(FontCache as any)._getTextHeightCache = {};
@@ -26,6 +27,7 @@ describe('font cache', () => {
});
afterEach(() => {
invalidateDocumentFontMetrics(() => true);
vi.unstubAllGlobals();
vi.restoreAllMocks();
});
@@ -79,6 +81,51 @@ describe('font cache', () => {
expect(FontCache.getMeasureText('Agent', '14px FontChange')).toEqual(next);
});
it('preserves fractional normal spacing across font sizes and transfers it to a DOM-free Worker', () => {
let normalHeight = 3066;
const measureRect = vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(() => ({
height: normalHeight,
} as DOMRect));
vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue({
font: '',
measureText: () => ({
width: 8,
fontBoundingBoxAscent: 14,
fontBoundingBoxDescent: 3,
actualBoundingBoxAscent: 11,
actualBoundingBoxDescent: 2,
}),
} as unknown as CanvasRenderingContext2D);
const style = {
fontString: '12pt FixtureFont',
fontCache: '12pt FixtureFont',
fontFamily: 'FixtureFont',
fontSize: 12,
originFontSize: 12,
};
const originalChildCount = document.body.childElementCount;
const main = FontCache.getTextSize('A', style, true);
expect(main.normalLineHeight).toBeCloseTo(18.396);
expect(FontCache.getTextSize('A', style).normalLineHeight).toBe(17);
expect(FontCache.getNormalLineHeight({ ...style, originFontSize: 10 })).toBeCloseTo(15.33);
expect(measureRect).toHaveBeenCalledTimes(1);
expect(document.body.childElementCount).toBe(originalChildCount);
const transferred = FontCache.getNormalLineHeightCache();
// Superscript keeps the paragraph's original font size for normal spacing.
expect(FontCache.getNormalLineHeight({ ...style, fontSize: 7.2, fontString: '7.2pt FixtureFont' }))
.toBeCloseTo(main.normalLineHeight!);
invalidateDocumentFontMetrics((font) => font === style.fontString);
normalHeight = 3200;
expect(FontCache.getTextSize('A', style, true).normalLineHeight).toBeCloseTo(19.2);
vi.stubGlobal('document', undefined);
invalidateDocumentFontMetrics(() => true);
FontCache.setNormalLineHeightCache(transferred);
expect(FontCache.getTextSize('A', style, true)).toEqual(main);
expect(measureRect).toHaveBeenCalledTimes(2);
});
it('measures text with OffscreenCanvas when the DOM is unavailable', () => {
const measureText = vi.fn(() => ({
width: 16,
@@ -37,6 +37,10 @@ const getDefaultBaselineOffset = (fontSize: number) => ({
spo: fontSize,
});
function getNormalFontKey(font: string): string {
return font.replace(/\d+(?:\.\d+)?(?:pt|px)/, '1000pt').trim();
}
interface IFontData {
notDefWidth: number;
ascender: number;
@@ -64,6 +68,8 @@ export function invalidateDocumentFontMetrics(matches: (fontStyle: string) => bo
}
export class FontCache {
private static _normalLineHeightCache = new Map<string, number>();
private static _getTextHeightCache: { [key: string]: { width: number; height: number } } = {};
private static _context: CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D | null;
@@ -78,11 +84,17 @@ export class FontCache {
static invalidateMetrics(matches: (fontStyle: string) => boolean): boolean {
let changed = false;
const keys = new Set([...this._globalFontMeasureCache.keys(), ...Object.keys(this._getTextHeightCache)]);
const keys = new Set([
...this._globalFontMeasureCache.keys(),
...Object.keys(this._getTextHeightCache),
...this._normalLineHeightCache.keys(),
]);
for (const key of keys) {
if (matches(key)) {
this._globalFontMeasureCache.delete(key);
delete this._getTextHeightCache[key];
this._normalLineHeightCache.delete(key);
this._normalLineHeightCache.delete(getNormalFontKey(key));
changed = true;
}
}
@@ -209,7 +221,55 @@ export class FontCache {
return result;
}
static getTextSize(content: string, fontStyle: IDocumentSkeletonFontStyle): IDocumentSkeletonBoundingBox {
/** CSS normal spacing includes font leading that Canvas bounding boxes omit. */
static getNormalLineHeight(fontStyle: IDocumentSkeletonFontStyle): number | undefined {
const key = getNormalFontKey(fontStyle.fontString);
let ratio = this._normalLineHeightCache.get(key);
if (ratio == null && typeof document !== 'undefined' && document.body != null) {
const element = document.createElement('div');
element.style.cssText = 'position:fixed;visibility:hidden;pointer-events:none;white-space:pre;margin:0;padding:0;border:0;';
element.style.font = key;
element.style.lineHeight = 'normal';
element.textContent = 'Hg\nHg';
document.body.appendChild(element);
try {
// Measuring large text preserves subpixel font metrics at ordinary document sizes.
const height = element.getBoundingClientRect().height / 2000;
if (Number.isFinite(height) && height > 0) {
ratio = height;
if (this._normalLineHeightCache.size >= 1024) {
this._normalLineHeightCache.clear();
}
this._normalLineHeightCache.set(key, ratio);
}
} finally {
element.remove();
}
}
return ratio == null ? undefined : ratio * fontStyle.originFontSize;
}
/** Transfers browser-only normal spacing measurements to the document layout Worker. */
static getNormalLineHeightCache(): Record<string, number> {
return Object.fromEntries(this._normalLineHeightCache);
}
static setNormalLineHeightCache(metrics: Record<string, number>): void {
for (const [font, ratio] of Object.entries(metrics)) {
if (Number.isFinite(ratio) && ratio > 0) {
if (this._normalLineHeightCache.size >= 1024 && !this._normalLineHeightCache.has(font)) {
this._normalLineHeightCache.clear();
}
this._normalLineHeightCache.set(font, ratio);
}
}
}
static getTextSize(
content: string,
fontStyle: IDocumentSkeletonFontStyle,
includeNormalFontLeading = false
): IDocumentSkeletonBoundingBox {
const { fontString, fontSize, fontFamily } = fontStyle;
let bBox = this._getBoundingBoxByFont(fontFamily, fontSize);
@@ -224,7 +284,7 @@ export class FontCache {
return {
...bBox,
normalLineHeight: bBox.ba + bBox.bd,
normalLineHeight: (includeNormalFontLeading ? this.getNormalLineHeight(fontStyle) : undefined) ?? bBox.ba + bBox.bd,
};
}
@@ -15,11 +15,14 @@
*/
import type { ICustomRangeForInterceptor, ITextStyle, Nullable } from '@univerjs/core';
import { BooleanNumber, CustomRangeType } from '@univerjs/core';
import { BaselineOffset, BooleanNumber, CustomRangeType } from '@univerjs/core';
const CUSTOM_RANGE_COLOR_TOKEN = 'blue.600';
export function getCustomRangeStyle(customRange: ICustomRangeForInterceptor): Nullable<ITextStyle> {
if ((customRange.rangeType === CustomRangeType.FOOTNOTE || customRange.rangeType === CustomRangeType.ENDNOTE)) {
return { va: BaselineOffset.SUPERSCRIPT };
}
if (
customRange.rangeType === CustomRangeType.HYPERLINK ||
customRange.rangeType === CustomRangeType.MENTION ||
@@ -49,8 +49,11 @@ import type { IBoundRectNoAngle } from '../../../basics/vector2';
import type { IDocumentCompatibilityPolicy } from '../document-compatibility';
import type { DataStreamTreeNode } from '../view-model/data-stream-tree-node';
import type { DocumentViewModel } from '../view-model/document-view-model';
import type { DocumentEndnoteLayout } from './endnote-layout';
import type { DocumentFootnoteLayout } from './footnote-layout';
import type { Hyphen } from './hyphenation/hyphen';
import type { LanguageDetector } from './hyphenation/language-detector';
import type { INoteReferenceLayout } from './note-numbering';
import {
AlignTypeH,
AlignTypeV,
@@ -1591,6 +1594,7 @@ export function getGlyphGroupWidth(divide: IDocumentSkeletonDivide) {
}
interface IFontCreateConfig {
documentCompatibilityPolicy?: IDocumentCompatibilityPolicy;
fontStyle: IDocumentSkeletonFontStyle;
textStyle: ITextStyle;
charSpace: number;
@@ -1625,6 +1629,7 @@ export function getFontConfigFromLastGlyph(
const pageWidth = pageSize.width || Number.POSITIVE_INFINITY - marginLeft - marginRight;
const result = {
documentCompatibilityPolicy: sectionBreakConfig.documentCompatibilityPolicy ?? getDocumentCompatibilityPolicy(),
fontStyle: fontStyle!,
textStyle: ts!,
charSpace,
@@ -1813,6 +1818,10 @@ export function getNullSkeleton(): IDocumentSkeletonCached {
export function setPageParent(pages: IDocumentSkeletonPage[], parent: IDocumentSkeletonCached) {
for (const page of pages) {
page.parent = parent;
for (const note of page.notes ?? []) {
note.parent = page;
note.page.parent = note;
}
}
}
@@ -1854,6 +1863,15 @@ export interface IDocumentPaginationMetrics {
}
export interface ILayoutContext {
noteReferences?: ReadonlyMap<number, INoteReferenceLayout>;
footnoteLayout?: DocumentFootnoteLayout;
endnoteLayout?: DocumentEndnoteLayout;
/** Virtual marker in a note body; it never consumes a persisted character. */
noteLabel?: string;
noteReferenceTextStyle?: ITextStyle;
/** Preserve the local note segment when the paragraph/table pipeline opens a continuation page. */
noteSegmentId?: string;
footnoteFirstColumn?: { index: number; top: number };
// The view model of current layout document.
viewModel: DocumentViewModel;
// The data model of current layout document.
@@ -2156,6 +2174,13 @@ export function getPageFromPath(skeletonData: IDocumentSkeletonCached, path: (st
const cellIndex = pathCopy.shift() as number;
page = page.skeTables?.get(tableId)?.rows[rowIndex]?.cells[cellIndex];
} else if (field === 'notes') {
if (page == null) {
return null;
}
const footnoteIndex = pathCopy.shift() as number;
pathCopy.shift(); // page
page = page.notes?.[footnoteIndex]?.page;
} else if (field === 'skeColumnGroups') {
if (page == null) {
return null;
@@ -84,6 +84,7 @@ function createDocumentDataModel(overrides?: {
getSnapshot: vi.fn(() => snapshot),
headerModelMap: overrides?.headerModelMap ?? new Map(),
footerModelMap: overrides?.footerModelMap ?? new Map(),
noteModelMap: new Map(),
} as any;
}
@@ -98,6 +99,35 @@ function findFirstNodeByType(node: any, type: DataStreamTreeNodeType): any | nul
}
describe('DocumentViewModel', () => {
it('rebuilds an edited footnote tree without rebuilding unrelated note trees', () => {
const noteBody = (text: string) => ({
dataStream: `${text}\r\n`,
paragraphs: [{ paragraphId: `${text}-p`, startIndex: text.length }],
sectionBreaks: [{ sectionId: `${text}-s`, startIndex: text.length + 1 }],
});
const model = new DocumentDataModel({
id: 'notes',
body: noteBody('Body'),
notes: {
first: { type: 'footnote' as const, noteId: 'first', body: noteBody('First') },
second: { type: 'footnote' as const, noteId: 'second', body: noteBody('Second') },
},
});
const view = new DocumentViewModel(model);
const first = view.getNoteTreeMap().get('first');
const second = view.getNoteTreeMap().get('second');
try {
model.apply(JSONX.getInstance().editOp(new TextX().insert(4, { dataStream: 'New ' }).serialize(), ['notes', 'first', 'body']));
view.reset(model);
expect(view.getNoteTreeMap().get('first')).not.toBe(first);
expect(view.getNoteTreeMap().get('second')).toBe(second);
expect(view.getNoteTreeMap().get('first')?.getChildren()[0].children[0].content).toBe('New First\r\n');
} finally {
view.dispose();
model.dispose();
}
});
describe('parseDataStreamToTree', () => {
it('should handle empty section correctly', () => {
const dataStream = DataStreamTreeTokenType.SECTION_BREAK;
@@ -14,7 +14,23 @@
* limitations under the License.
*/
import type { DocumentDataModel, IColumnGroup, ICustomBlock, ICustomColumnGroup, ICustomDecorationForInterceptor, ICustomRangeForInterceptor, ICustomTable, IDisposable, IParagraph, ISectionBreak, ITable, ITextRun, JSONXActions, JSONXPath, Nullable } from '@univerjs/core';
import type {
DocumentDataModel,
IColumnGroup,
ICustomBlock,
ICustomColumnGroup,
ICustomDecorationForInterceptor,
ICustomRangeForInterceptor,
ICustomTable,
IDisposable,
IParagraph,
ISectionBreak,
ITable,
ITextRun,
JSONXActions,
JSONXPath,
Nullable,
} from '@univerjs/core';
import {
DataStreamTreeNodeType,
DataStreamTreeTokenType,
@@ -540,6 +556,7 @@ export class DocumentViewModel implements IDisposable {
private _headerTreeMap: Map<string, DocumentViewModel> = new Map();
private _footerTreeMap: Map<string, DocumentViewModel> = new Map();
private _noteTreeMap: Map<string, DocumentViewModel> = new Map();
private readonly _segmentViewModels$ = new BehaviorSubject<DocumentViewModel[]>([]);
readonly segmentViewModels$ = this._segmentViewModels$.asObservable();
@@ -583,7 +600,8 @@ export class DocumentViewModel implements IDisposable {
this._plainTopLevelParagraphNodes = [];
this._lastTextRun = null;
// this._headerTreeMap.clear();
// this._footerTreeMap.clear();
this._noteTreeMap.forEach((viewModel) => viewModel.dispose());
this._noteTreeMap.clear();
this._segmentViewModels$.complete();
this._editAreaChange$.complete();
}
@@ -595,6 +613,10 @@ export class DocumentViewModel implements IDisposable {
};
}
getNoteTreeMap(): ReadonlyMap<string, DocumentViewModel> {
return this._noteTreeMap;
}
getEditArea() {
return this._editArea;
}
@@ -635,6 +657,10 @@ export class DocumentViewModel implements IDisposable {
return this._footerTreeMap.get(segmentId)!;
}
if (this._noteTreeMap.has(segmentId)) {
return this._noteTreeMap.get(segmentId)!;
}
return this as DocumentViewModel;
}
@@ -1138,6 +1164,12 @@ export class DocumentViewModel implements IDisposable {
}
private _buildHeaderFooterViewModel() {
for (const [noteId, viewModel] of this._noteTreeMap) {
if (viewModel.getDataModel() !== this._documentDataModel.noteModelMap.get(noteId)) {
viewModel.dispose();
this._noteTreeMap.delete(noteId);
}
}
const { headerModelMap, footerModelMap } = this._documentDataModel;
const viewModels = [];
const rootTableSource = this.getSnapshot().tableSource;
@@ -1157,6 +1189,12 @@ export class DocumentViewModel implements IDisposable {
viewModels.push(this._footerTreeMap.get(footerId)!);
}
for (const [noteId, model] of this._documentDataModel.noteModelMap) {
const viewModel = this._noteTreeMap.get(noteId)
?? new DocumentViewModel(model, { ...rootTableSource, ...model.getSnapshot().tableSource });
this._noteTreeMap.set(noteId, viewModel);
viewModels.push(viewModel);
}
this._segmentViewModels$.next(viewModels);
}
}