UNPKG

@itwin/core-backend

Version:
856 lines (850 loc) 90.1 kB
/*--------------------------------------------------------------------------------------------- * Copyright (c) Bentley Systems, Incorporated. All rights reserved. * See LICENSE.md in the project root for license terms and full copyright notice. *--------------------------------------------------------------------------------------------*/ import { expect } from "chai"; import { computeGraphemeOffsets, layoutTextBlock, TextStyleResolver } from "../../annotations/TextBlockLayout"; import { Geometry } from "@itwin/core-geometry"; import { ColorDef, FontType, FractionRun, LineBreakRun, List, ListMarkerEnumerator, Paragraph, TabRun, TextAnnotation, TextBlock, TextRun, TextStyleSettings } from "@itwin/core-common"; import { IModelTestUtils } from "../IModelTestUtils"; import { ProcessDetector } from "@itwin/core-bentley"; import { produceTextBlockGeometry } from "../../core-backend"; import { computeTextRangeAsStringLength, doLayout } from "../AnnotationTestUtils"; function makeTextRun(content) { return TextRun.create({ content }); } function isIntlSupported() { // Node in the mobile add-on does not include Intl, so this test fails. Right now, mobile // users are not expected to do any editing, but long term we will attempt to find a better // solution. return !ProcessDetector.isMobileAppBackend; } function findTextStyleImpl(id) { if (id === "0x42") { return TextStyleSettings.fromJSON({ lineSpacingFactor: 12, font: { name: "block" }, isBold: true }); } return TextStyleSettings.fromJSON({ lineSpacingFactor: 1, font: { name: "other" } }); } describe("layoutTextBlock", () => { describe("resolves TextStyleSettings", () => { it("inherits styling from TextBlock when Paragraph and Run have no style overrides", () => { const textBlock = TextBlock.create(); const run = TextRun.create({ content: "test" }); textBlock.appendParagraph(); textBlock.appendRun(run); const tb = doLayout(textBlock, { textStyleId: "0x42", findTextStyle: findTextStyleImpl, }); expect(tb.lines.length).to.equal(1); expect(tb.lines[0].runs.length).to.equal(1); const runStyle = tb.lines[0].runs[0].style; expect(runStyle.font.name).to.equal("block"); expect(runStyle.lineSpacingFactor).to.equal(12); expect(runStyle.isBold).to.be.true; }); it("inherits style overrides from Paragraph when Run has no style overrides", () => { const textBlock = TextBlock.create(); textBlock.appendParagraph({ styleOverrides: { font: { name: "paragraph" } } }); textBlock.appendRun(TextRun.create({ content: "test" })); const tb = doLayout(textBlock, { textStyleId: "0x42", findTextStyle: findTextStyleImpl, }); expect(tb.lines.length).to.equal(1); expect(tb.lines[0].runs.length).to.equal(1); const runStyle = tb.lines[0].runs[0].style; expect(runStyle.font.name).to.equal("paragraph"); expect(runStyle.isBold).to.be.true; }); it("uses Run style overrides when Run has overrides", () => { const textBlock = TextBlock.create(); textBlock.appendParagraph({ styleOverrides: { lineSpacingFactor: 55, font: { name: "paragraph" } } }); textBlock.appendRun(TextRun.create({ content: "test", styleOverrides: { lineSpacingFactor: 99, font: { name: "run" } } })); const tb = doLayout(textBlock, { textStyleId: "0x42", findTextStyle: findTextStyleImpl, }); expect(tb.lines.length).to.equal(1); expect(tb.lines[0].runs.length).to.equal(1); const runStyle = tb.lines[0].runs[0].style; expect(runStyle.font.name).to.equal("run"); expect(runStyle.isBold).to.be.true; }); it("still uses TextBlock specific styles when Run has style overrides", () => { // Some style settings make sense on a TextBlock, so they are always applied from the TextBlock, even if the Run has a style override. const textBlock = TextBlock.create(); const run = TextRun.create({ content: "test", styleOverrides: { lineSpacingFactor: 99, font: { name: "run" } } }); textBlock.appendParagraph(); textBlock.appendRun(run); const tb = doLayout(textBlock, { textStyleId: "0x42", findTextStyle: findTextStyleImpl, }); expect(tb.lines.length).to.equal(1); expect(tb.lines[0].runs.length).to.equal(1); const runStyle = tb.lines[0].runs[0].style; expect(runStyle.lineSpacingFactor).to.equal(12); }); it("inherits overrides from TextBlock, Paragraph and Run when there is no styleId", () => { const textBlock = TextBlock.create({ styleOverrides: { widthFactor: 34, textHeight: 3, lineSpacingFactor: 12, paragraphSpacingFactor: 2, isBold: true } }); const run = TextRun.create({ content: "test", styleOverrides: { widthFactor: 78, font: { name: "override" }, leader: { wantElbow: true } } }); textBlock.appendParagraph({ styleOverrides: { textHeight: 56, paragraphSpacingFactor: 25, color: 0xff0000, frame: { shape: "octagon" } } }); textBlock.appendRun(run); const tb = doLayout(textBlock, { findTextStyle: findTextStyleImpl, }); expect(tb.lines.length).to.equal(1); expect(tb.lines[0].runs.length).to.equal(1); const runStyle = tb.lines[0].runs[0].style; // widthFactor is always taken from the TextBlock, even if the Run has overrides expect(runStyle.widthFactor).to.equal(34); // paragraphSpacingFactor is always taken from the TextBlock, even if the Run has overrides expect(runStyle.paragraphSpacingFactor).to.equal(2); // lineSpacingFactor is always taken from the TextBlock, even if the Run has overrides expect(runStyle.lineSpacingFactor).to.equal(12); // frame settings are always taken from the TextBlock, even if the Paragraph or Run has overrides expect(runStyle.frame.shape).to.equal("none"); // leader settings are always taken from the TextBlock, even if the Paragraph or Run has overrides expect(runStyle.leader.wantElbow).to.be.false; expect(runStyle.font.name).to.equal("override"); expect(runStyle.color).to.equal(0xff0000); expect(runStyle.isBold).to.be.true; expect(runStyle.textHeight).to.equal(56); }); it("does not inherit overrides in TextBlock or Paragraph when Run has same propertied overriden - unless they are TextBlock specific settings", () => { const textBlock = TextBlock.create({ styleOverrides: { widthFactor: 34, margins: { left: 3 }, textHeight: 3, lineSpacingFactor: 12, paragraphSpacingFactor: 2, isBold: true, justification: "center" } }); const run = TextRun.create({ content: "test", styleOverrides: { widthFactor: 78, margins: { left: 4, right: 3 }, textHeight: 6, paragraphSpacingFactor: 25, lineSpacingFactor: 24, font: { name: "override" }, isBold: false, justification: "right" } }); textBlock.appendParagraph({ styleOverrides: { textHeight: 56, paragraphSpacingFactor: 50, color: 0xff0000, justification: "left" } }); textBlock.appendRun(run); const tb = doLayout(textBlock, { textStyleId: "0x42", findTextStyle: findTextStyleImpl, }); expect(tb.lines.length).to.equal(1); expect(tb.lines[0].runs.length).to.equal(1); const runStyle = tb.lines[0].runs[0].style; // widthFactor is always taken from the TextBlock, even if the Run has a styleId or overrides expect(runStyle.widthFactor).to.equal(34); // paragraphSpacingFactor is always taken from the TextBlock, even if the Run has overrides expect(runStyle.paragraphSpacingFactor).to.equal(2); // lineSpacingFactor is always taken from the TextBlock, even if the Run has a styleId or overrides expect(runStyle.lineSpacingFactor).to.equal(12); // margins are always taken from the TextBlock, even if the Paragraph or Run has overrides expect(runStyle.margins.left).to.equal(3); expect(runStyle.margins.right).to.equal(0); // justification is always taken from the TextBlock, even if the Paragraph or Run has overrides expect(runStyle.justification).to.equal("center"); expect(runStyle.font.name).to.equal("override"); expect(runStyle.color).to.equal(0xff0000); expect(runStyle.isBold).to.be.false; expect(runStyle.textHeight).to.equal(6); }); it("takes child overrides over parent overrides", () => { //...unless they are TextBlock specific as covered in other tests const textBlock = TextBlock.create({ styleOverrides: { font: { name: "grandparent" } } }); const run = TextRun.create({ content: "test", styleOverrides: { font: { name: "child" } } }); textBlock.appendParagraph({ styleOverrides: { font: { name: "parent" } } }); textBlock.appendRun(run); const tb = doLayout(textBlock, { findTextStyle: findTextStyleImpl, }); expect(tb.lines.length).to.equal(1); expect(tb.lines[0].runs.length).to.equal(1); const runStyle = tb.lines[0].runs[0].style; expect(runStyle.font.name).to.equal("child"); }); }); it("has consistent data when converted to a layout result", function () { if (!isIntlSupported()) { this.skip(); } // Initialize a new TextBlockLayout object const textBlock = TextBlock.create({ width: 50, styleOverrides: { widthFactor: 34, color: 0x00ff00, font: { name: "arial" } } }); const run0 = TextRun.create({ content: "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Phasellus pretium mi sit amet magna malesuada, at venenatis ante eleifend.", styleOverrides: { textHeight: 56, color: 0xff0000 }, }); const run1 = TextRun.create({ content: "Donec sit amet semper sapien. Nullam commodo, libero a accumsan lacinia, metus enim pharetra lacus, eu facilisis sem nisi eu dui.", styleOverrides: { widthFactor: 78, font: { name: "run1" } }, }); const run2 = TextRun.create({ content: "Duis dui quam, suscipit quis feugiat id, fermentum ut augue. Mauris iaculis odio rhoncus lorem eleifend, posuere viverra turpis elementum.", styleOverrides: {}, }); const fractionRun = FractionRun.create({ numerator: "num", denominator: "denom", styleOverrides: {} }); textBlock.appendRun(run0); textBlock.appendRun(fractionRun); textBlock.appendParagraph(); textBlock.appendRun(run1); textBlock.appendRun(run2); // Call the toResult() method const textBlockLayout = doLayout(textBlock, { findFontId: (fontName) => { if (fontName === "arial") { return 1; } else if (fontName === "run1") { return 2; } return 0; }, }); const result = textBlockLayout.toResult(); // Assert that the result object has the same data as the original TextBlockLayout object expect(result.range).to.deep.equal(textBlockLayout.range.toJSON()); expect(result.lines.length).to.equal(textBlockLayout.lines.length); // Loop through each line in the result and the original object for (let i = 0; i < result.lines.length; i++) { const resultLine = result.lines[i]; const originalLine = textBlockLayout.lines[i]; // Ranges match expect(resultLine.range).to.deep.equal(originalLine.range.toJSON()); expect(resultLine.justificationRange).to.deep.equal(originalLine.justificationRange.toJSON()); // Offset matches expect(resultLine.offsetFromDocument).to.deep.equal(originalLine.offsetFromDocument); for (let j = 0; j < resultLine.runs.length; j++) { const resultRun = resultLine.runs[j]; const originalRun = originalLine.runs[j]; // FontId matches expect(resultRun.fontId).to.equal(originalRun.fontId); // Offsets match expect(resultRun.characterOffset).to.equal(originalRun.charOffset); expect(resultRun.characterCount).to.equal(originalRun.numChars); expect(resultRun.offsetFromLine).to.deep.equal(originalRun.offsetFromLine); // Range matches expect(resultRun.range).to.deep.equal(originalRun.range.toJSON()); // Text style matches expect(resultRun.textStyle).to.deep.equal(originalRun.style.toJSON()); // Optional values match existence and values if (resultRun.justificationRange) { expect(originalRun.justificationRange); } if (originalRun.justificationRange) { expect(resultRun.justificationRange); } if (resultRun.justificationRange && originalRun.justificationRange) { expect(resultRun.justificationRange).to.deep.equal(originalRun.justificationRange.toJSON()); } if (resultRun.numeratorRange) { expect(originalRun.numeratorRange); } if (originalRun.numeratorRange) { expect(resultRun.numeratorRange); } if (resultRun.numeratorRange && originalRun.numeratorRange) { expect(resultRun.numeratorRange).to.deep.equal(originalRun.numeratorRange.toJSON()); } if (resultRun.denominatorRange) { expect(originalRun.denominatorRange); } if (originalRun.denominatorRange) { expect(resultRun.denominatorRange); } if (resultRun.denominatorRange && originalRun.denominatorRange) { expect(resultRun.denominatorRange).to.deep.equal(originalRun.denominatorRange.toJSON()); } // Check that the result string matches what we expect const inputRun = originalRun.source; if (inputRun.type === "text") { const resultText = inputRun.content.substring(resultRun.characterOffset, resultRun.characterOffset + resultRun.characterCount); const originalText = inputRun.content.substring(originalRun.charOffset, originalRun.charOffset + originalRun.numChars); expect(resultText).to.equal(originalText); } } } }); it("adds margins", function () { const expectMargins = (layoutRange, marginRange, margins) => { expect(marginRange.low.x).to.equal(layoutRange.low.x - (margins.left ?? 0)); expect(marginRange.high.x).to.equal(layoutRange.high.x + (margins.right ?? 0)); expect(marginRange.low.y).to.equal(layoutRange.low.y - (margins.bottom ?? 0)); expect(marginRange.high.y).to.equal(layoutRange.high.y + (margins.top ?? 0)); }; const textBlock = TextBlock.create({ styleOverrides: { lineSpacingFactor: 0 } }); textBlock.appendRun(makeTextRun("abc")); textBlock.appendRun(makeTextRun("defg")); const marginStyleCallback = (margins) => { return () => TextStyleSettings.fromJSON({ margins: { ...margins } }); }; let layout = doLayout(textBlock, { findTextStyle: marginStyleCallback({}), }); // Margins should be 0 by default expect(layout.range.isAlmostEqual(layout.textRange)).to.be.true; expectMargins(layout.textRange, layout.range, {}); // All margins should be applied to the range layout = doLayout(textBlock, { findTextStyle: marginStyleCallback({ left: 1, right: 2, top: 3, bottom: 4 }), }); expectMargins(layout.textRange, layout.range, { left: 1, right: 2, top: 3, bottom: 4 }); // Just horizontal margins should be applied layout = doLayout(textBlock, { findTextStyle: marginStyleCallback({ left: 1, right: 2 }), }); expectMargins(layout.textRange, layout.range, { left: 1, right: 2 }); // Just vertical margins should be applied layout = doLayout(textBlock, { findTextStyle: marginStyleCallback({ top: 1, bottom: 2 }), }); expectMargins(layout.textRange, layout.range, { top: 1, bottom: 2 }); }); describe("range", () => { const round = (num, numDecimalPlaces) => { const multiplier = Math.pow(100, numDecimalPlaces); return Math.round(num * multiplier) / multiplier; }; it("aligns text of the same size on the bottom of the line", () => { const textBlock = TextBlock.create(); const run1 = TextRun.create({ content: "abc" }); const run2 = TextRun.create({ content: "defg" }); textBlock.appendRun(run1); textBlock.appendRun(run2); const layout = doLayout(textBlock); const run1Layout = layout.lines[0].runs[0]; const run2Layout = layout.lines[0].runs[1]; expect(run1Layout.range.yLength()).to.equal(1); expect(run2Layout.range.yLength()).to.equal(1); expect(run1Layout.offsetFromLine.y).to.equal(0); expect(run2Layout.offsetFromLine.y).to.equal(0); }); it("aligns text of varying sizes to the baseline of the largest text", () => { const textBlock = TextBlock.create(); const smallText = TextRun.create({ content: "small", styleOverrides: { textHeight: 1 } }); const largeText = TextRun.create({ content: "large", styleOverrides: { textHeight: 3 } }); textBlock.appendRun(smallText); textBlock.appendRun(largeText); const layout = doLayout(textBlock); const smallLayout = layout.lines[0].runs[0]; const largeLayout = layout.lines[0].runs[1]; expect(smallLayout.range.yLength()).to.equal(1); expect(largeLayout.range.yLength()).to.equal(3); expect(largeLayout.offsetFromLine.y).to.equal(0); expect(smallLayout.offsetFromLine.y).to.equal(0); }); it("aligns text to center based on height of the largest stacked fraction", () => { const textBlock = TextBlock.create(); const fractionRun = FractionRun.create({ numerator: "1", denominator: "2", styleOverrides: { textHeight: 4 } }); const textRun = TextRun.create({ content: "text", styleOverrides: { textHeight: 2 } }); textBlock.appendRun(fractionRun); textBlock.appendRun(textRun); const layout = doLayout(textBlock); const fractionLayout = layout.lines[0].runs[0]; const textLayout = layout.lines[0].runs[1]; expect(round(fractionLayout.range.yLength(), 2)).to.equal(7); expect(textLayout.range.yLength()).to.equal(2); // Fraction should be defining the line height expect(fractionLayout.offsetFromLine.y).to.equal(0); expect(round(textLayout.offsetFromLine.y, 2)).to.equal(2.5); }); it("aligns the largest non-fraction text to the center based on height of stacked fraction and aligns all other text to the baseline", () => { const textBlock = TextBlock.create(); const smallText = TextRun.create({ content: "s", styleOverrides: { textHeight: 1 } }); const mediumText = TextRun.create({ content: "m", styleOverrides: { textHeight: 2 } }); const fraction = FractionRun.create({ numerator: "1", denominator: "2", styleOverrides: { textHeight: 4 } }); textBlock.appendRun(smallText); textBlock.appendRun(mediumText); textBlock.appendRun(fraction); const layout = doLayout(textBlock); const smallLayout = layout.lines[0].runs[0]; const mediumLayout = layout.lines[0].runs[1]; const fractionLayout = layout.lines[0].runs[2]; expect(smallLayout.range.yLength()).to.equal(1); expect(mediumLayout.range.yLength()).to.equal(2); expect(round(fractionLayout.range.yLength(), 2)).to.equal(7); expect(round(mediumLayout.offsetFromLine.y, 2)).to.equal(2.5); expect(round(smallLayout.offsetFromLine.y, 2)).to.equal(2.5); expect(fractionLayout.offsetFromLine.y).to.equal(0); }); it("aligns fractions to the baseline of same sized text", () => { const textBlock = TextBlock.create(); const text = TextRun.create({ content: "t", styleOverrides: { textHeight: 3 } }); const fraction = FractionRun.create({ numerator: "1", denominator: "2", styleOverrides: { textHeight: 3 } }); textBlock.appendRun(text); textBlock.appendRun(fraction); const layout = doLayout(textBlock); const textLayout = layout.lines[0].runs[0]; const fractionLayout = layout.lines[0].runs[1]; expect(textLayout.range.yLength()).to.equal(3); expect(round(fractionLayout.range.yLength(), 2)).to.equal(5.25); expect(round(textLayout.offsetFromLine.y, 3)).to.equal(1.125); // Slightly lower than text baseline so that the fraction appears centered on the text expect(round(fractionLayout.offsetFromLine.y, 3)).to.equal(0.075); }); it("produces one line per paragraph if document width <= 0", () => { const lineSpacingFactor = 0.5; const paragraphSpacingFactor = 0.25; const textBlock = TextBlock.create({ styleOverrides: { paragraphSpacingFactor, lineSpacingFactor } }); for (let i = 0; i < 4; i++) { const layout = doLayout(textBlock); if (i === 0) { expect(layout.range.isNull).to.be.true; } else { expect(layout.lines.length).to.equal(i); expect(layout.range.low.x).to.equal(0); expect(layout.range.low.y).to.equal(-i - ((i - 1) * (lineSpacingFactor + paragraphSpacingFactor))); expect(layout.range.high.x).to.equal(i * 3); expect(layout.range.high.y).to.equal(0); } for (let l = 0; l < layout.lines.length; l++) { const line = layout.lines[l]; expect(line.runs.length).to.equal(l + 1); expect(line.range.low.x).to.equal(0); expect(line.range.low.y).to.equal(0); expect(line.range.high.y).to.equal(1); expect(line.range.high.x).to.equal(3 * (l + 1)); for (const run of line.runs) { expect(run.charOffset).to.equal(0); expect(run.numChars).to.equal(3); expect(run.range.low.x).to.equal(0); expect(run.range.low.y).to.equal(0); expect(run.range.high.x).to.equal(3); expect(run.range.high.y).to.equal(1); } } const p = textBlock.appendParagraph(); for (let j = 0; j <= i; j++) { p.children.push(TextRun.create({ content: "Run" })); } } }); it("produces a new line for each LineBreakRun", () => { const lineSpacingFactor = 0.5; const textHeight = 1; const textBlock = TextBlock.create({ styleOverrides: { lineSpacingFactor, textHeight } }); textBlock.appendRun(TextRun.create({ content: "abc" })); textBlock.appendRun(LineBreakRun.create()); textBlock.appendRun(TextRun.create({ content: "def" })); textBlock.appendRun(TextRun.create({ content: "ghi" })); textBlock.appendRun(LineBreakRun.create()); textBlock.appendRun(TextRun.create({ content: "jkl" })); const tb = doLayout(textBlock); expect(tb.lines.length).to.equal(3); expect(tb.lines[0].runs.length).to.equal(2); expect(tb.lines[1].runs.length).to.equal(3); expect(tb.lines[2].runs.length).to.equal(1); expect(tb.range.low.x).to.equal(0); expect(tb.range.high.x).to.equal(6); expect(tb.range.high.y).to.equal(0); // paragraphSpacingFactor should not be applied to linebreaks, but lineSpacingFactor should. expect(tb.range.low.y).to.equal(-(lineSpacingFactor * 2 + textHeight * 3)); }); it("applies tab shifts", () => { const textHeight = 1; const tabInterval = 6; const textBlock = TextBlock.create({ styleOverrides: { textHeight, tabInterval } }); // Appends a line that looks like `stringOne` TAB `stringTwo` LINEBREAK const appendLine = (stringOne, stringTwo, wantLineBreak = true) => { if (stringOne.length > 0) textBlock.appendRun(TextRun.create({ content: stringOne })); textBlock.appendRun(TabRun.create({ styleOverrides: { tabInterval } })); if (stringTwo.length > 0) textBlock.appendRun(TextRun.create({ content: stringTwo })); if (wantLineBreak) textBlock.appendRun(LineBreakRun.create()); }; // The extra comments are intentional to show where the tab stops should be. appendLine("", /*______*/ "a"); appendLine("", /*______*/ "bc"); appendLine("a", /*_____*/ "a"); appendLine("bc", /*____*/ "bc"); appendLine("cde", /*___*/ "cde"); appendLine("cdefg", /*_*/ "cde"); // this one is the max tab distance before needing to move to the next tab stop appendLine("cdefgh", /*______*/ "cde"); // This one should push to the next tab stop. appendLine("cdefghi", /*_____*/ "cde", false); // This one should push to the next tab stop. const tb = doLayout(textBlock); tb.lines.forEach((line, index) => { const firstTextRun = (line.runs[0].source.type === "text") ? line.runs[0] : undefined; const firstTabRun = (line.runs[0].source.type === "tab") ? line.runs[0] : line.runs[1]; const distance = (firstTextRun?.range.xLength() ?? 0) + firstTabRun.range.xLength(); const expectedDistance = ((firstTextRun?.range.xLength() || 0) >= tabInterval) ? tabInterval * 2 : tabInterval; expect(distance).to.equal(expectedDistance, `Line ${index} does not have the expected tab distance. ${expectedDistance}`); }); }); it("applies consecutive tab shifts", () => { const textHeight = 1; const tabInterval = 6; const textBlock = TextBlock.create({ styleOverrides: { textHeight, tabInterval } }); // line 0: ----->----->----->LINEBREAK textBlock.appendRun(TabRun.create({ styleOverrides: { tabInterval } })); textBlock.appendRun(TabRun.create({ styleOverrides: { tabInterval } })); textBlock.appendRun(TabRun.create({ styleOverrides: { tabInterval } })); textBlock.appendRun(LineBreakRun.create()); // line 1: abc-->----->LINEBREAK textBlock.appendRun(TextRun.create({ content: "abc" })); textBlock.appendRun(TabRun.create({ styleOverrides: { tabInterval } })); textBlock.appendRun(TabRun.create({ styleOverrides: { tabInterval } })); textBlock.appendRun(LineBreakRun.create()); // line 2: abc--->->------>LINEBREAK textBlock.appendRun(TextRun.create({ content: "abc" })); textBlock.appendRun(TabRun.create({ styleOverrides: { tabInterval: 7 } })); textBlock.appendRun(TabRun.create({ styleOverrides: { tabInterval: 2 } })); textBlock.appendRun(TabRun.create({ styleOverrides: { tabInterval: 7 } })); textBlock.appendRun(LineBreakRun.create()); // line 3: abc--->1/23->abcde->LINEBREAK textBlock.appendRun(TextRun.create({ content: "abc" })); textBlock.appendRun(TabRun.create({ styleOverrides: { tabInterval: 7 } })); textBlock.appendRun(FractionRun.create({ numerator: "1", denominator: "23" })); textBlock.appendRun(TabRun.create({ styleOverrides: { tabInterval: 3 } })); textBlock.appendRun(TextRun.create({ content: "abcde" })); textBlock.appendRun(TabRun.create({ styleOverrides: { tabInterval: 7 } })); textBlock.appendRun(LineBreakRun.create()); const tb = doLayout(textBlock); const line0 = tb.lines[0]; const line1 = tb.lines[1]; const line2 = tb.lines[2]; const line3 = tb.lines[3]; expect(line0.runs.length).to.equal(4); expect(line0.range.xLength()).to.equal(3 * tabInterval, `Lines with tabs should have the correct range length`); expect(line1.runs.length).to.equal(4); expect(line1.range.xLength()).to.equal(2 * tabInterval, `Tabs should be applied correctly when they are at the end of a line`); expect(line2.runs.length).to.equal(5); expect(line2.range.xLength()).to.equal(7 + 2 + 7, `Multiple tabs with different intervals should be applied correctly`); expect(line3.runs.length).to.equal(7); expect(line3.range.xLength()).to.equal(7 + 3 + 7, `Multiple tabs with different intervals should be applied correctly`); }); it("computes ranges based on custom line spacing, text height, and indentation", () => { const lineSpacingFactor = 2; const textHeight = 3; const paragraphSpacingFactor = 13; const indentation = 7; const textBlock = TextBlock.create({ styleOverrides: { lineSpacingFactor, textHeight, paragraphSpacingFactor, indentation } }); textBlock.appendRun(TextRun.create({ content: "abc" })); textBlock.appendRun(LineBreakRun.create()); textBlock.appendRun(TextRun.create({ content: "def" })); textBlock.appendRun(TextRun.create({ content: "ghi" })); textBlock.appendRun(LineBreakRun.create()); textBlock.appendRun(TextRun.create({ content: "jkl" })); const tb = doLayout(textBlock); expect(tb.lines.length).to.equal(3); expect(tb.lines[0].runs.length).to.equal(2); expect(tb.lines[1].runs.length).to.equal(3); expect(tb.lines[2].runs.length).to.equal(1); /* Final TextBlock should look like: ⇥abc↵ ⇥defghi↵ ⇥jkl Where ↵ = LineBreak, ¶ = ParagraphBreak, ⇥ = indentation We have 3 lines each `textHeight` high, plus 2 line breaks in between each `textHeight*lineSpacingFactor` high. No paragraph spacing should be applied since there is one paragraph. */ expect(tb.range.low.x).to.equal(7); expect(tb.range.high.x).to.equal(6 + 7); // 7 for indentation, 6 for the length of "defghi" expect(tb.range.high.y).to.equal(0); expect(tb.range.low.y).to.equal(-(textHeight * 3 + (textHeight * lineSpacingFactor) * 2)); expect(tb.lines[0].offsetFromDocument.y).to.equal(-textHeight); expect(tb.lines[1].offsetFromDocument.y).to.equal(tb.lines[0].offsetFromDocument.y - (textHeight + textHeight * lineSpacingFactor)); expect(tb.lines[2].offsetFromDocument.y).to.equal(tb.lines[1].offsetFromDocument.y - (textHeight + textHeight * lineSpacingFactor)); tb.lines.forEach((line) => expect(line.offsetFromDocument.x).to.equal(7)); }); it("computes paragraph spacing and indentation", () => { const lineSpacingFactor = 2; const textHeight = 3; const paragraphSpacingFactor = 13; const indentation = 7; const tabInterval = 5; const textBlock = TextBlock.create({ styleOverrides: { lineSpacingFactor, textHeight, paragraphSpacingFactor, indentation, tabInterval } }); const p1 = textBlock.appendParagraph(); p1.children.push(TextRun.create({ content: "abc" })); // Line 1 p1.children.push(LineBreakRun.create()); p1.children.push(TextRun.create({ content: "def" })); // Line 2 const p2 = textBlock.appendParagraph(); p2.children.push(TextRun.create({ content: "ghi" })); // Line 3 const list = List.create(); list.children.push(Paragraph.create({ children: [{ type: "text", content: "list item 1" }] })); // Line 4 list.children.push(Paragraph.create({ children: [{ type: "text", content: "list item 2" }] })); // Line 5 list.children.push(Paragraph.create({ children: [{ type: "text", content: "list item 3" }] })); // Line 6 p2.children.push(list); const tb = doLayout(textBlock); expect(tb.lines.length).to.equal(6); /* Final TextBlock should look like: ⇥abc↵ ⇥def¶ ⇥ghi¶ ⇥→1. list item 1¶ ⇥→2. list item 2¶ ⇥→3. list item 3 Where ↵ = LineBreak, ¶ = ParagraphBreak, → = tabInterval/2, ⇥ = indentation We have: 6 lines each `textHeight` high 5 line breaks in between each `textHeight*lineSpacingFactor` high 4 paragraph breaks in between each `textHeight*paragraphSpacingFactor` high */ expect(tb.range.low.x).to.equal(7); // 7 for indentation expect(tb.range.high.x).to.equal(7 + 5 + 11); // 7 for indentation, 5 for the tab stop, 11 for the length of "list item 1" expect(tb.range.high.y).to.equal(0); expect(tb.range.low.y).to.equal(-(textHeight * 6 + (textHeight * lineSpacingFactor) * 5 + (textHeight * paragraphSpacingFactor) * 4)); // Cumulative vertical offsets to help make the test more readable. let offsetY = -textHeight; let offsetX = indentation; expect(tb.lines[0].offsetFromDocument.y).to.equal(offsetY); expect(tb.lines[0].offsetFromDocument.x).to.equal(offsetX); offsetY -= (textHeight + textHeight * lineSpacingFactor); expect(tb.lines[1].offsetFromDocument.y).to.equal(offsetY); expect(tb.lines[1].offsetFromDocument.x).to.equal(offsetX); offsetY -= (textHeight + textHeight * lineSpacingFactor + textHeight * paragraphSpacingFactor); expect(tb.lines[2].offsetFromDocument.y).to.equal(offsetY); expect(tb.lines[2].offsetFromDocument.x).to.equal(offsetX); offsetX += tabInterval; // List items are indented using tabInterval. offsetY -= (textHeight + textHeight * lineSpacingFactor + textHeight * paragraphSpacingFactor); expect(tb.lines[3].offsetFromDocument.y).to.equal(offsetY); expect(tb.lines[3].offsetFromDocument.x).to.equal(offsetX); offsetY -= (textHeight + textHeight * lineSpacingFactor + textHeight * paragraphSpacingFactor); expect(tb.lines[4].offsetFromDocument.y).to.equal(offsetY); expect(tb.lines[4].offsetFromDocument.x).to.equal(offsetX); offsetY -= (textHeight + textHeight * lineSpacingFactor + textHeight * paragraphSpacingFactor); expect(tb.lines[5].offsetFromDocument.y).to.equal(offsetY); expect(tb.lines[5].offsetFromDocument.x).to.equal(offsetX); }); function expectRange(width, height, range) { expect(range.xLength()).to.equal(width); expect(range.yLength()).to.equal(height); } it("computes range for wrapped lines", function () { if (!isIntlSupported()) { this.skip(); } const block = TextBlock.create({ width: 3, styleOverrides: { textHeight: 1, lineSpacingFactor: 0 } }); function expectBlockRange(width, height) { const layout = doLayout(block); expectRange(width, height, layout.range); } block.appendRun(makeTextRun("abc")); expectBlockRange(3, 1); block.appendRun(makeTextRun("defg")); expectBlockRange(4, 2); block.width = 1; expectBlockRange(4, 2); block.width = 8; expectBlockRange(8, 1); block.width = 6; expectBlockRange(6, 2); block.width = 10; expectBlockRange(10, 1); block.appendRun(makeTextRun("hijk")); expectBlockRange(10, 2); }); it("computes range for split runs", function () { if (!isIntlSupported()) { this.skip(); } const block = TextBlock.create({ styleOverrides: { textHeight: 1, lineSpacingFactor: 0 } }); function expectBlockRange(width, height) { const layout = doLayout(block); expectRange(width, height, layout.range); } const sentence = "a bc def ghij klmno"; expect(sentence.length).to.equal(19); block.appendRun(makeTextRun(sentence)); block.width = 19; expectBlockRange(19, 1); block.width = 10; expectBlockRange(10, 2); }); it("computes range for list markers and list items based on indentation", function () { const lineSpacingFactor = 2; const textHeight = 3; const paragraphSpacingFactor = 13; const indentation = 7; const tabInterval = 5; const listChildren = [ { children: [ { type: "text", content: "Oranges", } ] }, { children: [ { type: "text", content: "Apples", }, { type: "list", styleOverrides: { listMarker: { enumerator: ListMarkerEnumerator.Bullet } }, children: [ { children: [ { type: "text", content: "Red", } ] }, { children: [ { type: "text", content: "Green", }, { type: "list", styleOverrides: { listMarker: { enumerator: ListMarkerEnumerator.RomanNumeral, case: "lower", terminator: "period" } }, children: [ { children: [ { type: "text", content: "Granny Smith", } ] }, { children: [ { type: "text", content: "Rhode Island Greening", } ] } ] } ] }, { children: [ { type: "text", content: "Yellow", } ] } ] } ] } ]; const textBlock = TextBlock.create({ styleOverrides: { lineSpacingFactor, textHeight, paragraphSpacingFactor, indentation, tabInterval } }); const p1 = textBlock.appendParagraph(); p1.children.push(List.create({ children: listChildren })); /* Final TextBlock should look like: →1.→Oranges¶ →2.→Apples¶ →→•→Red¶ →→•→Green¶ → →→i. →Granny Smith¶ → →→ii.→Rhode Island Greening¶ →→•→Yellow Where ↵ = LineBreak, ¶ = ParagraphBreak, → = tab, → = tabInterval/2, ⇥ = indentation We have: 7 lines each `textHeight` high 6 line breaks in between each `textHeight*lineSpacingFactor` high 6 paragraph breaks in between each `textHeight*paragraphSpacingFactor` high */ const tb = doLayout(textBlock); expect(tb.lines.length).to.equal(7); expect(tb.range.low.x).to.equal(7 + 5 - 5 / 2 - 2); // indentation + tabInterval - tabInterval/2 (for marker offset) + 2 (for the marker "1." justification, it's 2 characters wide) expect(tb.range.high.x).to.equal(7 + 3 * 5 + 21); // 7 for indentation, 3 * 5 for the most nested tab stops, 21 for the length of "Rhode Island Greening" expect(tb.range.high.y).to.equal(0); expect(tb.range.low.y).to.equal(-(textHeight * 7 + (textHeight * lineSpacingFactor) * 6 + (textHeight * paragraphSpacingFactor) * 6)); // Cumulative vertical offsets to help make the test more readable. let offsetY = -textHeight; for (const line of tb.lines) { expect(line.offsetFromDocument.y).to.equal(offsetY); expect(line.marker).to.not.be.undefined; expect(line.marker?.offsetFromLine.y).to.equal((textHeight - line.marker.range.yLength()) / 2); offsetY -= (textHeight + textHeight * lineSpacingFactor + textHeight * paragraphSpacingFactor); } let markerXLength = tb.lines[0].marker.range.xLength(); let inset = indentation + tabInterval; expect(tb.lines[0].offsetFromDocument.x).to.equal(inset); // →Oranges expect(markerXLength).to.equal(2); // "1." is 2 characters wide expect(tb.lines[0].marker.offsetFromLine.x).to.equal(0 - markerXLength - (tabInterval / 2)); markerXLength = tb.lines[1].marker.range.xLength(); expect(tb.lines[1].offsetFromDocument.x).to.equal(inset); // →Apples expect(tb.lines[1].marker.offsetFromLine.x).to.equal(0 - markerXLength - (tabInterval / 2)); markerXLength = tb.lines[2].marker.range.xLength(); inset = indentation + tabInterval * 2; expect(tb.lines[2].offsetFromDocument.x).to.equal(indentation + tabInterval * 2); // →→Red expect(tb.lines[2].marker.offsetFromLine.x).to.equal(0 - markerXLength - (tabInterval / 2)); markerXLength = tb.lines[3].marker.range.xLength(); expect(tb.lines[3].offsetFromDocument.x).to.equal(indentation + tabInterval * 2); // →→Green expect(tb.lines[3].marker.offsetFromLine.x).to.equal(0 - markerXLength - (tabInterval / 2)); markerXLength = tb.lines[4].marker.range.xLength(); expect(tb.lines[4].offsetFromDocument.x).to.equal(indentation + tabInterval * 3); // →→→Granny Smith expect(tb.lines[4].marker.offsetFromLine.x).to.equal(0 - markerXLength - (tabInterval / 2)); markerXLength = tb.lines[5].marker.range.xLength(); expect(tb.lines[5].offsetFromDocument.x).to.equal(indentation + tabInterval * 3); // →→→Rhode Island Greening expect(tb.lines[5].marker.offsetFromLine.x).to.equal(0 - markerXLength - (tabInterval / 2)); markerXLength = tb.lines[6].marker.range.xLength(); expect(tb.lines[6].offsetFromDocument.x).to.equal(indentation + tabInterval * 2); // →→Yellow expect(tb.lines[6].marker.offsetFromLine.x).to.equal(0 - markerXLength - (tabInterval / 2)); }); it("justifies lines", function () { if (!isIntlSupported()) { this.skip(); } const block = TextBlock.create({ styleOverrides: { lineSpacingFactor: 0 } }); function expectBlockRange(width, height, justification) { const layout = doLayout(block, { findTextStyle: () => TextStyleSettings.fromJSON({ justification }) }); expectRange(width, height, layout.range); } function expectLineOffset(offset, lineIndex, justification) { const layout = doLayout(block, { findTextStyle: () => TextStyleSettings.fromJSON({ justification }), }); expect(layout.lines.length).least(lineIndex + 1); const line = layout.lines[lineIndex]; expect(line.offsetFromDocument.y).to.equal(-(lineIndex + 1)); expect(line.offsetFromDocument.x).to.equal(offset); } // Two text runs with 7 characters total. block.appendRun(makeTextRun("abc")); block.appendRun(makeTextRun("defg")); // 1 line of text with width 0: left, right, center justification. expectBlockRange(7, 1, "left"); expectLineOffset(0, 0, "left"); expectBlockRange(7, 1, "right"); expectLineOffset(0, 0, "right"); expectBlockRange(7, 1, "center"); expectLineOffset(0, 0, "center"); // 1 line of text from a width greater than number of characters: left, right, center justification. block.width = 10; expectBlockRange(10, 1, "left"); expectLineOffset(0, 0, "left"); expectBlockRange(10, 1, "right"); expectLineOffset(3, 0, "right"); // 3 = 10 - 7 expectBlockRange(10, 1, "center"); expectLineOffset(1.5, 0, "center"); // 1.5 = (10 - 7) / 2 // 2 line of text from a width less than number of characters: left, right, center justification. block.width = 4; expectBlockRange(4, 2, "left"); expectLineOffset(0, 0, "left"); expectLineOffset(0, 1, "left"); expectBlockRange(4, 2, "right"); expectLineOffset(1, 0, "right"); expectLineOffset(0, 1, "right"); expectBlockRange(4, 2, "center"); expectLineOffset(0.5, 0, "center"); expectLineOffset(0, 1, "center"); // Testing text longer the the width of the text block. block.width = 2; expectBlockRange(4, 2, "left"); expectLineOffset(0, 0, "left"); expectLineOffset(0, 1, "left"); expectBlockRange(4, 2, "right"); expectLineOffset(-1, 0, "right"); expectLineOffset(-2, 1, "right"); block.appendRun(makeTextRun("123456789")); expectBlockRange(9, 3, "right"); expectLineOffset(-1, 0, "right"); expectLineOffset(-2, 1, "right"); expectLineOffset(-7, 2, "right"); expectBlockRange(9, 3, "center"); expectLineOffset(-0.5, 0, "center"); expectLineOffset(-1, 1, "center"); expectLineOffset(-3.5, 2, "center"); }); }); describe("word-wrapping", () => { function expectLines(input, width, expectedLines) { const textBlock = TextBlock.create({ styleOverrides: { paragraphSpacingFactor: 0, lineSpacingFactor: 0, textHeight: 1 } }); textBlock.width = width; const r