UNPKG

@itwin/core-backend

Version:
941 lines 63.8 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, 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, fontName: "block", isBold: true }); } return TextStyleSettings.fromJSON({ lineSpacingFactor: 1, fontName: "other" }); } describe("layoutTextBlock", () => { describe("resolves TextStyleSettings", () => { it("inherits styling from TextBlock when Paragraph and Run have no style overrides", () => { const textBlock = TextBlock.create({ styleId: "0x42" }); const run = TextRun.create({ content: "test" }); textBlock.appendParagraph(); 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.fontName).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({ styleId: "0x42" }); const paragraph = Paragraph.create({ styleOverrides: { fontName: "paragraph" } }); const run = TextRun.create({ content: "test" }); textBlock.paragraphs.push(paragraph); 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.fontName).to.equal("paragraph"); expect(runStyle.isBold).to.be.true; }); it("uses Run style overrides when Run has overrides", () => { const textBlock = TextBlock.create({ styleId: "0x42" }); const paragraph = Paragraph.create({ styleOverrides: { lineSpacingFactor: 55, fontName: "paragraph" } }); const run = TextRun.create({ content: "test", styleOverrides: { lineSpacingFactor: 99, fontName: "run" } }); textBlock.paragraphs.push(paragraph); 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.fontName).to.equal("run"); expect(runStyle.isBold).to.be.true; }); it("still uses TextBlock specific styles when Run has style overrides", () => { // Some style settings only 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({ styleId: "0x42" }); const run = TextRun.create({ content: "test", styleOverrides: { lineSpacingFactor: 99, fontName: "run" } }); textBlock.appendParagraph(); 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.lineSpacingFactor).to.equal(12); }); it("inherits overrides from TextBlock, Paragraph and Run when there is no styleId", () => { const textBlock = TextBlock.create({ styleId: "", styleOverrides: { widthFactor: 34, lineHeight: 3, lineSpacingFactor: 12, isBold: true } }); const paragraph = Paragraph.create({ styleOverrides: { lineHeight: 56, color: 0xff0000, frame: { shape: "octagon" } } }); const run = TextRun.create({ content: "test", styleOverrides: { widthFactor: 78, fontName: "override", leader: { wantElbow: true } } }); textBlock.paragraphs.push(paragraph); 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); // lineHeight is always taken from the TextBlock, even if the Run has overrides expect(runStyle.lineHeight).to.equal(3); // 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.fontName).to.equal("override"); expect(runStyle.color).to.equal(0xff0000); expect(runStyle.isBold).to.be.true; }); 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({ styleId: "0x42", styleOverrides: { widthFactor: 34, lineHeight: 3, lineSpacingFactor: 12, isBold: true } }); const paragraph = Paragraph.create({ styleOverrides: { lineHeight: 56, color: 0xff0000 } }); const run = TextRun.create({ content: "test", styleOverrides: { widthFactor: 78, lineHeight: 6, lineSpacingFactor: 24, fontName: "override", isBold: false } }); textBlock.paragraphs.push(paragraph); 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 a styleId or overrides expect(runStyle.widthFactor).to.equal(34); // lineHeight is always taken from the TextBlock, even if the Run has a styleId or overrides expect(runStyle.lineHeight).to.equal(3); // lineSpacingFactor is always taken from the TextBlock, even if the Run has a styleId or overrides expect(runStyle.lineSpacingFactor).to.equal(12); expect(runStyle.fontName).to.equal("override"); expect(runStyle.color).to.equal(0xff0000); expect(runStyle.isBold).to.be.false; }); it("takes child overrides over parent overrides", () => { //...unless they are TextBlock specific as covered in other tests const textBlock = TextBlock.create({ styleId: "", styleOverrides: { fontName: "grandparent" } }); const paragraph = Paragraph.create({ styleOverrides: { fontName: "parent" } }); const run = TextRun.create({ content: "test", styleOverrides: { fontName: "child" } }); textBlock.paragraphs.push(paragraph); 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.fontName).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, styleId: "", styleOverrides: { widthFactor: 34, color: 0x00ff00, fontName: "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: { lineHeight: 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, fontName: "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]; // Source paragraph index matches expect(resultLine.sourceParagraphIndex).to.equal(textBlock.paragraphs.indexOf(originalLine.source)); // 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]; // Source run index matches expect(resultRun.sourceRunIndex).to.equal(textBlock.paragraphs[resultLine.sourceParagraphIndex].runs.indexOf(originalRun.source)); // 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 = textBlock.paragraphs[resultLine.sourceParagraphIndex].runs[resultRun.sourceRunIndex].clone(); 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 makeTextBlock = (margins) => { const textBlock = TextBlock.create({ styleId: "", styleOverrides: { lineSpacingFactor: 0 }, margins }); textBlock.appendRun(makeTextRun("abc")); textBlock.appendRun(makeTextRun("defg")); return textBlock; }; let block = makeTextBlock({}); let layout = doLayout(block); // 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 block = makeTextBlock({ left: 1, right: 2, top: 3, bottom: 4 }); layout = doLayout(block); expectMargins(layout.textRange, layout.range, { left: 1, right: 2, top: 3, bottom: 4 }); // Just horizontal margins should be applied block = makeTextBlock({ left: 1, right: 2 }); layout = doLayout(block); expectMargins(layout.textRange, layout.range, { left: 1, right: 2 }); // Just vertical margins should be applied block = makeTextBlock({ top: 1, bottom: 2 }); layout = doLayout(block); expectMargins(layout.textRange, layout.range, { top: 1, bottom: 2 }); }); describe("range", () => { it("aligns text to center based on height of stacked fraction", () => { const textBlock = TextBlock.create({ styleId: "" }); const fractionRun = FractionRun.create({ numerator: "1", denominator: "2" }); const textRun = TextRun.create({ content: "text" }); textBlock.appendRun(fractionRun); textBlock.appendRun(textRun); const layout = doLayout(textBlock); const fractionLayout = layout.lines[0].runs[0]; const textLayout = layout.lines[0].runs[1]; const round = (num, numDecimalPlaces) => { const multiplier = Math.pow(100, numDecimalPlaces); return Math.round(num * multiplier) / multiplier; }; expect(textLayout.range.yLength()).to.equal(1); expect(round(fractionLayout.range.yLength(), 2)).to.equal(1.75); expect(fractionLayout.offsetFromLine.y).to.equal(0); expect(round(textLayout.offsetFromLine.y, 3)).to.equal(.375); }); it("produces one line per paragraph if document width <= 0", () => { const textBlock = TextBlock.create({ styleId: "" }); 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 - (0.5 * (i - 1))); // lineSpacingFactor=0.5 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.runs.push(TextRun.create({ content: "Run" })); } } }); it("produces a new line for each LineBreakRun", () => { const lineSpacingFactor = 0.5; const lineHeight = 1; const textBlock = TextBlock.create({ styleId: "", styleOverrides: { lineSpacingFactor, lineHeight } }); 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); expect(tb.range.low.y).to.equal(-(lineSpacingFactor * 2 + lineHeight * 3)); }); it("applies tab shifts", () => { const lineHeight = 1; const tabInterval = 6; const textBlock = TextBlock.create({ styleId: "", styleOverrides: { lineHeight, 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 whitespace is 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 lineHeight = 1; const tabInterval = 6; const textBlock = TextBlock.create({ styleId: "", styleOverrides: { lineHeight, 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 only 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 and line height", () => { const lineSpacingFactor = 2; const lineHeight = 3; const textBlock = TextBlock.create({ styleId: "", styleOverrides: { lineSpacingFactor, lineHeight } }); 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); // We have 3 lines each `lineHeight` high, plus 2 line breaks in between each `lineHeight*lineSpacingFactor` high. expect(tb.range.low.x).to.equal(0); expect(tb.range.high.x).to.equal(6); expect(tb.range.high.y).to.equal(0); expect(tb.range.low.y).to.equal(-(lineHeight * 3 + (lineHeight * lineSpacingFactor) * 2)); expect(tb.lines[0].offsetFromDocument.y).to.equal(-lineHeight); expect(tb.lines[1].offsetFromDocument.y).to.equal(tb.lines[0].offsetFromDocument.y - (lineHeight + lineHeight * lineSpacingFactor)); expect(tb.lines[2].offsetFromDocument.y).to.equal(tb.lines[1].offsetFromDocument.y - (lineHeight + lineHeight * lineSpacingFactor)); expect(tb.lines.every((line) => line.offsetFromDocument.x === 0)).to.be.true; }); 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({ styleId: "", width: 3, styleOverrides: { lineHeight: 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({ styleId: "", styleOverrides: { lineHeight: 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("justifies lines", function () { if (!isIntlSupported()) { this.skip(); } const block = TextBlock.create({ styleId: "", styleOverrides: { lineSpacingFactor: 0 } }); function expectBlockRange(width, height) { const layout = doLayout(block); expectRange(width, height, layout.range); } function expectLineOffset(offset, lineIndex) { const layout = doLayout(block); 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. block.justification = "left"; expectBlockRange(7, 1); expectLineOffset(0, 0); block.justification = "right"; expectBlockRange(7, 1); expectLineOffset(0, 0); block.justification = "center"; expectBlockRange(7, 1); expectLineOffset(0, 0); // 1 line of text from a width greater than number of characters: left, right, center justification. block.width = 10; block.justification = "left"; expectBlockRange(10, 1); expectLineOffset(0, 0); block.justification = "right"; expectBlockRange(10, 1); expectLineOffset(3, 0); // 3 = 10 - 7 block.justification = "center"; expectBlockRange(10, 1); expectLineOffset(1.5, 0); // 1.5 = (10 - 7) / 2 // 2 line of text from a width less than number of characters: left, right, center justification. block.justification = "left"; block.width = 4; expectBlockRange(4, 2); expectLineOffset(0, 0); expectLineOffset(0, 1); block.justification = "right"; expectBlockRange(4, 2); expectLineOffset(1, 0); expectLineOffset(0, 1); block.justification = "center"; expectBlockRange(4, 2); expectLineOffset(0.5, 0); expectLineOffset(0, 1); // Testing text longer the the width of the text block. block.width = 2; block.justification = "left"; expectBlockRange(4, 2); expectLineOffset(0, 0); expectLineOffset(0, 1); block.justification = "right"; expectBlockRange(4, 2); expectLineOffset(-1, 0); expectLineOffset(-2, 1); block.appendRun(makeTextRun("123456789")); expectBlockRange(9, 3); expectLineOffset(-1, 0); expectLineOffset(-2, 1); expectLineOffset(-7, 2); block.justification = "center"; expectBlockRange(9, 3); expectLineOffset(-0.5, 0); expectLineOffset(-1, 1); expectLineOffset(-3.5, 2); }); }); describe("word-wrapping", () => { function expectLines(input, width, expectedLines) { const textBlock = TextBlock.create({ styleId: "" }); textBlock.width = width; const run = makeTextRun(input); textBlock.appendRun(run); const layout = doLayout(textBlock); expect(layout.lines.every((line) => line.runs.every((r) => r.source === run))).to.be.true; const actual = layout.lines.map((line) => line.runs.map((runLayout) => runLayout.source.content.substring(runLayout.charOffset, runLayout.charOffset + runLayout.numChars)).join("")); expect(actual).to.deep.equal(expectedLines); return layout; } it("splits paragraphs into multiple lines if runs exceed the document width", function () { if (!isIntlSupported()) { this.skip(); } const textBlock = TextBlock.create({ styleId: "" }); textBlock.width = 6; textBlock.appendRun(makeTextRun("ab")); expect(doLayout(textBlock).lines.length).to.equal(1); textBlock.appendRun(makeTextRun("cd")); expect(doLayout(textBlock).lines.length).to.equal(1); textBlock.appendRun(makeTextRun("ef")); expect(doLayout(textBlock).lines.length).to.equal(1); textBlock.appendRun(makeTextRun("ghi")); expect(doLayout(textBlock).lines.length).to.equal(2); textBlock.appendRun(makeTextRun("jklmnop")); expect(doLayout(textBlock).lines.length).to.equal(3); textBlock.appendRun(makeTextRun("q")); expect(doLayout(textBlock).lines.length).to.equal(4); textBlock.appendRun(makeTextRun("r")); expect(doLayout(textBlock).lines.length).to.equal(4); textBlock.appendRun(makeTextRun("stu")); expect(doLayout(textBlock).lines.length).to.equal(4); textBlock.appendRun(makeTextRun("vwxyz")); expect(doLayout(textBlock).lines.length).to.equal(5); }); it("splits a single TextRun at word boundaries if it exceeds the document width", function () { if (!isIntlSupported()) { this.skip(); } expectLines("a bc def ghij klmno pqrstu vwxyz", 5, [ "a bc ", "def ", "ghij ", "klmno ", "pqrstu ", "vwxyz", ]); const fox = "The quick brown fox jumped over the lazy dog"; expectLines(fox, 50, [fox]); expectLines(fox, 40, [ // 1 2 3 4 // 34567890123456789012345678901234567890 "The quick brown fox jumped over the ", "lazy dog", ]); expectLines(fox, 30, [ // 1 2 3 // 3456789012345678901234567890 "The quick brown fox jumped ", "over the lazy dog", ]); expectLines(fox, 20, [ // 1 2 // 345678901234567890 "The quick brown fox ", "jumped over the ", "lazy dog", ]); expectLines(fox, 10, [ // 1 // 234567890 "The quick ", "brown fox ", "jumped ", "over the ", "lazy dog", ]); }); it("considers consecutive whitespace part of a single 'word'", function () { if (!isIntlSupported()) { this.skip(); } expectLines("a b c d e f ", 3, [ "a ", "b ", "c ", "d ", "e ", "f ", ]); }); it("wraps Japanese text", function () { if (!isIntlSupported()) { this.skip(); } // "I am a cat. The name is Tanuki." expectLines("吾輩は猫である。名前はたぬき。", 1, ["吾", "輩", "は", "猫", "で", "あ", "る。", "名", "前", "は", "た", "ぬ", "き。"]); }); it("wraps tabs", function () { if (!isIntlSupported()) { this.skip(); } const lineHeight = 1; const textBlock = TextBlock.create({ styleId: "", styleOverrides: { lineHeight } }); // line 0: -->-->------> LINEBREAK textBlock.appendRun(TabRun.create({ styleOverrides: { tabInterval: 3 } })); textBlock.appendRun(TabRun.create({ styleOverrides: { tabInterval: 3 } })); textBlock.appendRun(TabRun.create({ styleOverrides: { tabInterval: 7 } })); textBlock.appendRun(LineBreakRun.create()); // line 1: a->b->cd-----> LINEBREAK textBlock.appendRun(TextRun.create({ content: "a" })); textBlock.appendRun(TabRun.create({ styleOverrides: { tabInterval: 3 } })); textBlock.appendRun(TextRun.create({ content: "b" })); textBlock.appendRun(TabRun.create({ styleOverrides: { tabInterval: 3 } })); textBlock.appendRun(TextRun.create({ content: "cd" })); textBlock.appendRun(TabRun.create({ styleOverrides: { tabInterval: 7 } })); textBlock.appendRun(LineBreakRun.create()); // line 2: -->a->b------>cd LINEBREAK textBlock.appendRun(TabRun.create({ styleOverrides: { tabInterval: 3 } })); textBlock.appendRun(TextRun.create({ content: "a" })); textBlock.appendRun(TabRun.create({ styleOverrides: { tabInterval: 3 } })); textBlock.appendRun(TextRun.create({ content: "b" })); textBlock.appendRun(TabRun.create({ styleOverrides: { tabInterval: 7 } })); textBlock.appendRun(TextRun.create({ content: "cd" })); textBlock.appendRun(LineBreakRun.create()); /* Full Width: * -->-->------> * a->b->cd----> * -->a->b----->cd */ let tb = doLayout(textBlock); expect(tb.lines.length).to.equal(3, ``); expect(tb.lines[0].range.xLength()).to.equal(13, ``); expect(tb.lines[1].range.xLength()).to.equal(13, ``); expect(tb.lines[2].range.xLength()).to.equal(15, ``); /* Width of 10: * -->--> * ------> * a->b->cd * ------> * -->a->b * ------>cd */ textBlock.width = 10; tb = doLayout(textBlock); expect(tb.lines.length).to.equal(6, ``); expect(tb.lines[0].range.xLength()).to.equal(6, ``); expect(tb.lines[1].range.xLength()).to.equal(7, ``); expect(tb.lines[2].range.xLength()).to.equal(8, ``); expect(tb.lines[3].range.xLength()).to.equal(7, ``); expect(tb.lines[4].range.xLength()).to.equal(7, ``); expect(tb.lines[5].range.xLength()).to.equal(9, ``); }); it("performs word-wrapping with punctuation", function () { if (!isIntlSupported()) { this.skip(); } expectLines("1.24 56.7 8,910", 1, ["1.24 ", "56.7 ", "8,910"]); expectLines("a.bc de.f g,hij", 1, ["a.bc ", "de.f ", "g,hij"]); expectLines("Let's see... can you (or anyone) predict?!", 1, [ "Let's ", "see... ", "can ", "you ", "(or ", "anyone) ", "predict?!", ]); }); it("performs word-wrapping and line-splitting with multiple runs", function () { if (!isIntlSupported()) { this.skip(); } const textBlock = TextBlock.create({ styleId: "" }); for (const str of ["The ", "quick brown", " fox jumped over ", "the lazy ", "dog"]) { textBlock.appendRun(makeTextRun(str)); } function test(width, expected) { textBlock.width = width; const layout = doLayout(textBlock); const actual = layout.lines.map((line) => line.runs.map((runLayout) => runLayout.source.content.substring(runLayout.charOffset, runLayout.charOffset + runLayout.numChars)).join("")); expect(actual).to.deep.equal(expected); } test(50, ["The quick brown fox jumped over the lazy dog"]); test(40, [ // 1 2 3 4 // 34567890123456789012345678901234567890 "The quick brown fox jumped over the ", "lazy dog", ]); test(30, [ // 1 2 3 // 3456789012345678901234567890 "The quick brown fox jumped ", "over the lazy dog", ]); test(20, [ // 1 2 // 345678901234567890 "The quick brown fox ", "jumped over the ", "lazy dog", ]); test(10, [ // 1 // 34567890 "The quick ", "brown fox ", "jumped ", "over the ", "lazy dog", ]); }); it("wraps multiple runs", function () { if (!isIntlSupported()) { this.skip(); } const block = TextBlock.create({ styleId: "" }); block.appendRun(makeTextRun("aa")); // 2 chars wide block.appendRun(makeTextRun("bb ccc d ee")); // 11 chars wide block.appendRun(makeTextRun("ff ggg h")); // 8 chars wide function expectLayout(width, expected) { block.width = width; const layout = doLayout(block); expect(layout.stringify()).to.equal(expected); } expectLayout(23, "aabb ccc d eeff ggg h"); expectLayout(22, "aabb ccc d eeff ggg h"); expectLayout(21, "aabb ccc d eeff ggg h"); expectLayout(20, "aabb ccc d eeff ggg \nh"); expectLayout(19, "aabb ccc d eeff \nggg h"); expectLayout(18, "aabb ccc d eeff \nggg h"); expectLayout(17, "aabb ccc d eeff \nggg h"); expectLayout(16, "aabb ccc d eeff \nggg h"); expectLayout(15, "aabb ccc d ee\nff ggg h"); expectLayout(14, "aabb ccc d ee\nff ggg h"); expectLayout(13, "aabb ccc d ee\nff ggg h"); expectLayout(12, "aabb ccc d \neeff ggg h"); expectLayout(11, "aabb ccc d \neeff ggg h"); expectLayout(10, "aabb ccc \nd eeff \nggg h"); expectLayout(9, "aabb ccc \nd eeff \nggg h"); expectLayout(8, "aabb \nccc d ee\nff ggg h"); expectLayout(7, "aabb \nccc d \neeff \nggg h"); expectLayout(6, "aabb \nccc d \neeff \nggg h"); expectLayout(5, "aabb \nccc \nd ee\nff \nggg h"); expectLayout(4, "aa\nbb \nccc \nd ee\nff \nggg \nh"); expectLayout(3, "aa\nbb \nccc \nd \nee\nff \nggg \nh"); expectLayout(2, "aa\nbb \nccc \nd \nee\nff \nggg \nh"); expectLayout(1, "aa\nbb \nccc \nd \nee\nff \nggg \nh"); expectLayout(0, "aabb ccc d eeff ggg h"); expectLayout(-1, "aabb ccc d eeff ggg h"); expectLayout(-2, "aabb ccc d eeff ggg h"); }); it("does not word wrap due to floating point rounding error", function () { if (!isIntlSupported()) { this.skip(); } const block = TextBlock.create({ styleId: "", styleOverrides: { lineHeight: 1, lineSpacingFactor: 0 } }); block.appendRun(makeTextRun("abc defg")); const layout1 = doLayout(block); let width = layout1.range.xLength(); // Simulate a floating point rounding error by slightly reducing the width width -= Geometry.smallFloatingPoint; block.width = width; const layout2 = doLayout(block); expect(layout2.range.yLength()).to.equal(1); }); }); describe("grapheme offsets", () => { function getLayoutResultAndStyleResolver(textBlock) { const layout = doLayout(textBlock); const result = layout.toResult(); const textStyleResolver = new TextStyleResolver({ textBlock, iModel: {}, modelId: undefined, findTextStyle: () => TextStyleSettings.defaults }); return { textStyleResolver, result }; } it("should return an empty array if source type is not text", function () { const textBlock = TextBlock.create({ styleId: "" }); const fractionRun = FractionRun.create({ numerator: "1", denominator: "2" }); textBlock.appendRun(fractionRun); const { textStyleResolver, result } = getLayoutResultAndStyleResolver(textBlock); const args = { textBlock, iModel: {}, textStyleResolver, findFontId: () => 0, computeTextRange: computeTextRangeAsStringLength, paragraphIndex: result.lines[0].sourceParagraphIndex, runLayoutResult: result.lines[0].runs[0], graphemeCharIndexes: [0], }; const graphemeRanges = computeGraphemeOffsets(args); expect(graphemeRanges).to.be.an("array").that.is.empty; }); it("should handle empty text content", function () { const textBlock = TextBlock.create({ styleId: "" }); const textRun = TextRun.create({ content: "" }); textBlock.appendRun(textRun); const { textStyleResolver, result } = getLayoutResultAndStyleResolver(textBlock); const args = { textBlock, iModel: {}, textStyleResolver, findFontId: () => 0, computeTextRange: computeTextRangeAsStringLength, paragraphIndex: result.lines[0].sourceParagraphIndex, runLayoutResult: result.lines[0].runs[0], graphemeCharIndexes: [0], // Supply a grapheme index even though there is no text }; const graphemeRanges = computeGraphemeOffsets(args); expect(graphemeRanges).to.be.an("array").that.is.empty; }); it("should compute grapheme offsets correctly for a given text", function () { const textBlock = TextBlock.create({ styleId: "" }); const textRun = TextRun.create({ content: "hello" }); textBlock.appendRun(textRun); const { textStyleResolver, result } = getLayoutResultAndStyleResolver(textBlock); const args = { textBlock, iModel: {}, textStyleResolver, findFontId: () => 0, computeTextRange: computeTextRangeAsStringLength, paragraphIndex: result.lines[0].sourceParagraphIndex, runLayoutResult: result.lines[0].runs[0], graphemeCharIndexes: [0, 1, 2, 3, 4], }; const graphemeRanges = computeGraphemeOffsets(args); expect(graphemeRanges).to.be.an("array").that.has.lengthOf(5); expect(graphemeRanges[0].high.x).to.equal(1); expect(graphemeRanges[4].high.x).to.equal(5); }); it("should compute grapheme offsets correctly for non-English text", function () { const textBlock = TextBlock.create({ styleId: "" }); // Hindi - "Paragraph" const textRun = TextRun.create({ content: "अनुच्छेद" }); textBlock.appendRun(textRun); const { textStyleResolver, result } = getLayoutResultAndStyleResolver(textBlock); const args = { textBlock, iModel: {}, textStyleResolver, findFontId: () => 0, computeTextRange: computeTextRangeAsStringLength, paragraphIndex: result.lines[0].sourceParagraphIndex, runLayoutResult: result.lines[0].runs[0], graphemeCharIndexes: [0, 1, 3, 7], }; const graphemeRanges = computeGraphemeOffsets(args); expect(graphemeRanges).to.be.an("array").that.has.lengthOf(4); // Length based on actual grapheme segmentation expect(graphemeRanges[0].high.x).to.equal(1); expect(graphemeRanges[1].high.x).to.equal(3); expect(graphe