@itwin/core-backend
Version:
iTwin.js backend components
941 lines • 63.8 kB
JavaScript
/*---------------------------------------------------------------------------------------------
* 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