@itwin/core-backend
Version:
iTwin.js backend components
647 lines • 28.1 kB
JavaScript
"use strict";
/*---------------------------------------------------------------------------------------------
* Copyright (c) Bentley Systems, Incorporated. All rights reserved.
* See LICENSE.md in the project root for license terms and full copyright notice.
*--------------------------------------------------------------------------------------------*/
/** @packageDocumentation
* @module ElementGeometry
*/
Object.defineProperty(exports, "__esModule", { value: true });
exports.TextBlockLayout = exports.LineLayout = exports.RunLayout = exports.TextStyleResolver = void 0;
exports.layoutTextBlock = layoutTextBlock;
exports.computeLayoutTextBlockResult = computeLayoutTextBlockResult;
exports.computeGraphemeOffsets = computeGraphemeOffsets;
const core_common_1 = require("@itwin/core-common");
const core_geometry_1 = require("@itwin/core-geometry");
const core_bentley_1 = require("@itwin/core-bentley");
const LineBreaker = require("linebreak");
const TextAnnotationElement_1 = require("./TextAnnotationElement");
const Element_1 = require("../Element");
/** @internal */
function createFindTextStyleImpl(iModel) {
return function findTextStyleImpl(id) {
const annotationTextStyle = iModel.elements.tryGetElement(id);
if (annotationTextStyle && annotationTextStyle instanceof TextAnnotationElement_1.AnnotationTextStyle) {
return annotationTextStyle.settings;
}
return core_common_1.TextStyleSettings.fromJSON();
};
}
/**
* Lays out the contents of a TextBlock into a series of lines containing runs.
* Each paragraph is decomposed into a series of lines.
* Each series of consecutive non-linebreak runs within a paragraph is concatenated into one line.
* If the document specifies a width > 0, individual lines are split to try to avoid exceeding that width.
* Individual TextRuns can be split onto multiple lines at word boundaries if necessary. Individual FractionRuns are never split.
* @see [[computeLayoutTextBlockResult]]
* @beta
*/
function layoutTextBlock(args) {
const findFontId = args.findFontId ?? ((name, type) => args.iModel.fonts.findId({ name, type }) ?? 0);
const computeTextRange = args.computeTextRange ?? ((x) => args.iModel.computeRangesForText(x));
return new TextBlockLayout(args.textBlock, new LayoutContext(args.textStyleResolver, computeTextRange, findFontId));
}
/**
* Gets the result of laying out the the contents of a TextBlock into a series of lines containing runs.
* The visual layout accounts for the [[AnnotationTextStyle]]s, fonts, and [TextBlock.width]($common). It applies word-wrapping if needed.
* The layout returned matches the visual layout of the geometry produced by [[appendTextAnnotationGeometry]].
* @beta
*/
function computeLayoutTextBlockResult(args) {
const layout = layoutTextBlock(args);
return layout.toResult();
}
;
/**
* Computes the range from the start of a [RunLayoutResult]($common) to the trailing edge of each grapheme.
* It is the responsibility of the caller to determine the number and character indexes of the graphemes.
* @returns If the [RunLayoutResult]($common)'s source is a [TextRun]($common), it returns an array containing the range of each grapheme.
* Otherwise, it returns and empty array.
* @beta
*/
function computeGraphemeOffsets(args) {
const { textBlock, paragraphIndex, runLayoutResult, graphemeCharIndexes, iModel } = args;
const findFontId = args.findFontId ?? ((name, type) => iModel.fonts.findId({ name, type }) ?? 0);
const computeTextRange = args.computeTextRange ?? ((x) => iModel.computeRangesForText(x));
const source = textBlock.paragraphs[paragraphIndex].runs[runLayoutResult.sourceRunIndex];
if (source.type !== "text" || runLayoutResult.characterCount === 0) {
return [];
}
const style = core_common_1.TextStyleSettings.fromJSON(runLayoutResult.textStyle);
const layoutContext = new LayoutContext(args.textStyleResolver, computeTextRange, findFontId);
const graphemeRanges = [];
graphemeCharIndexes.forEach((_, index) => {
const nextGraphemeCharIndex = graphemeCharIndexes[index + 1] ?? runLayoutResult.characterCount;
graphemeRanges.push(layoutContext.computeRangeForTextRun(style, source, runLayoutResult.characterOffset, nextGraphemeCharIndex).layout);
});
return graphemeRanges;
}
function scaleRange(range, scale) {
range.low.scaleInPlace(scale);
range.high.scaleInPlace(scale);
}
/**
* Applies block level settings (lineSpacingFactor, lineHeight, widthFactor, frame, and leader) to a [TextStyleSettings]($common).
* These must be set on the block, as they are meaningless on individual paragraphs/runs.
* However, leaders are a special case and can override the block's leader settings.
* Setting `isLeader` to `true` makes the [TextBlock]($common) settings not override the leader's settings.
* @internal
*/
function applyBlockSettings(target, source, isLeader = false) {
if (source === target) {
return target;
}
const lineSpacingFactor = source.lineSpacingFactor ?? target.lineSpacingFactor;
const lineHeight = source.lineHeight ?? target.lineHeight;
const widthFactor = source.widthFactor ?? target.widthFactor;
const frame = source.frame ?? target.frame;
const leader = source.leader ?? target.leader;
const leaderShouldChange = !isLeader && !target.leaderEquals(leader);
if (lineSpacingFactor !== target.lineSpacingFactor ||
lineHeight !== target.lineHeight ||
widthFactor !== target.widthFactor ||
!target.frameEquals(frame) ||
leaderShouldChange) {
const cloneProps = {
lineSpacingFactor,
lineHeight,
widthFactor,
frame,
};
if (leaderShouldChange) {
cloneProps.leader = leader;
}
target = target.clone(cloneProps);
}
return target;
}
/**
* Resolves the effective style of TextBlockComponents and Leaders, taking into account overrides/style of the instance and its parent(s).
* @beta
*/
class TextStyleResolver {
_textStyles = new Map();
_findTextStyle;
/** The resolved style of the TextBlock. */
blockSettings;
/** The scale factor of the model containing the TextBlock. */
scaleFactor;
constructor(args) {
this._findTextStyle = args.findTextStyle ?? createFindTextStyleImpl(args.iModel);
this.scaleFactor = 1;
if (args.modelId) {
const element = args.iModel.elements.getElement(args.modelId);
if (element instanceof Element_1.Drawing)
this.scaleFactor = element.scaleFactor;
}
this.blockSettings = this.findTextStyle(args.textBlock.styleId);
if (args.textBlock.styleOverrides)
this.blockSettings = this.blockSettings.clone(args.textBlock.styleOverrides);
}
resolveParagraphSettingsImpl(paragraph) {
let settings = this.blockSettings;
if (paragraph.overridesStyle)
settings = settings.clone(paragraph.styleOverrides);
return settings;
}
/** Looks up an [[AnnotationTextStyle]] by ID. Uses caching. */
findTextStyle(id) {
let style = this._textStyles.get(id);
if (undefined === style) {
this._textStyles.set(id, style = this._findTextStyle(id));
}
return style;
}
/** Resolves the effective style for a [TextAnnotationLeader]($common). The TextAnnotationLeader should be a sibling of the provided TextBlock. */
resolveTextAnnotationLeaderSettings(leader) {
let settings = this.blockSettings;
if (leader.styleOverrides)
settings = settings.clone(leader.styleOverrides);
return applyBlockSettings(settings, this.blockSettings, true);
}
/** Resolves the effective style for a [Paragraph]($common). Paragraph should be child of provided TextBlock. */
resolveParagraphSettings(paragraph) {
return applyBlockSettings(this.resolveParagraphSettingsImpl(paragraph), this.blockSettings);
}
/** Resolves the effective style for a [Run]($common). Run should be child of provided Paragraph and TextBlock. */
resolveRunSettings(paragraph, run) {
let settings = this.resolveParagraphSettingsImpl(paragraph);
if (run.overridesStyle)
settings = settings.clone(run.styleOverrides);
return applyBlockSettings(settings, this.blockSettings);
}
}
exports.TextStyleResolver = TextStyleResolver;
class LayoutContext {
textStyleResolver;
_computeTextRange;
_findFontId;
_fontIds = new Map();
constructor(textStyleResolver, _computeTextRange, _findFontId) {
this.textStyleResolver = textStyleResolver;
this._computeTextRange = _computeTextRange;
this._findFontId = _findFontId;
}
findFontId(name) {
let fontId = this._fontIds.get(name);
if (undefined === fontId) {
this._fontIds.set(name, fontId = this._findFontId(name));
}
return fontId;
}
computeRangeForText(chars, style, baselineShift) {
if (chars.length === 0) {
return {
layout: new core_geometry_1.Range2d(0, 0, 0, style.lineHeight),
justification: new core_geometry_1.Range2d(),
};
}
const fontId = this.findFontId(style.fontName);
const { layout, justification } = this._computeTextRange({
chars,
fontId,
baselineShift,
bold: style.isBold,
italic: style.isItalic,
lineHeight: this.textStyleResolver.blockSettings.lineHeight,
widthFactor: this.textStyleResolver.blockSettings.widthFactor,
});
if ("none" !== baselineShift) {
const isSub = "subscript" === baselineShift;
const scale = isSub ? style.subScriptScale : style.superScriptScale;
const offsetFactor = isSub ? style.subScriptOffsetFactor : style.superScriptOffsetFactor;
const offset = { x: 0, y: style.lineHeight * offsetFactor };
scaleRange(layout, scale);
layout.cloneTranslated(offset, layout);
scaleRange(justification, scale);
justification.cloneTranslated(offset, justification);
}
return { layout, justification };
}
computeRangeForTextRun(style, run, charOffset, numChars) {
let content;
let baselineShift;
if (run.type === "text") {
content = run.content;
baselineShift = run.baselineShift;
}
else {
content = run.cachedContent;
baselineShift = "none";
}
return this.computeRangeForText(content.substring(charOffset, charOffset + numChars), style, baselineShift);
}
computeRangeForFractionRun(style, source) {
const numerator = this.computeRangeForText(source.numerator, style, "none").layout;
scaleRange(numerator, style.stackedFractionScale);
const denominator = this.computeRangeForText(source.denominator, style, "none").layout;
scaleRange(denominator, style.stackedFractionScale);
const numLen = numerator.xLength();
const denomLen = denominator.xLength();
switch (style.stackedFractionType) {
case "horizontal": {
if (numLen > denomLen) {
denominator.cloneTranslated({ x: (numLen - denomLen) / 2, y: 0 }, denominator);
}
else {
numerator.cloneTranslated({ x: (denomLen - numLen) / 2, y: 0 }, numerator);
}
numerator.cloneTranslated({ x: 0, y: 1.5 * denominator.yLength() }, numerator);
break;
}
case "diagonal": {
numerator.cloneTranslated({ x: 0, y: denominator.yLength() }, numerator);
denominator.cloneTranslated({ x: numLen, y: 0 }, denominator);
break;
}
}
const layout = numerator.clone();
layout.extendRange(denominator);
return { layout, numerator, denominator };
}
computeRangeForTabRun(style, source, length) {
const interval = source.styleOverrides.tabInterval ?? style.tabInterval;
const tabEndX = interval - length % interval;
const range = new core_geometry_1.Range2d(0, 0, 0, style.lineHeight);
range.extendXY(tabEndX, range.low.y);
return range;
}
}
function split(source) {
if (source.length === 0) {
return [];
}
let index = 0;
const segments = [];
const breaker = new LineBreaker(source);
for (let brk = breaker.nextBreak(); brk; brk = breaker.nextBreak()) {
segments.push({
segment: source.slice(index, brk.position),
index,
});
index = brk.position;
}
return segments;
}
function applyTabShift(run, parent, context) {
if (run.source.type === "tab") {
run.range.setFrom(context.computeRangeForTabRun(run.style, run.source, parent.lengthFromLastTab));
}
}
/**
* Represents the layout of a single run (text, fraction, or line break) within a line of text.
* Stores information about the run's position, style, and font within the line.
* Provides utilities for splitting text runs for word wrapping and converting to result objects.
* @beta
*/
class RunLayout {
source;
charOffset;
numChars;
range;
justificationRange;
denominatorRange;
numeratorRange;
offsetFromLine;
style;
fontId;
constructor(props) {
this.source = props.source;
this.charOffset = props.charOffset;
this.numChars = props.numChars;
this.range = props.range;
this.justificationRange = props.justificationRange;
this.denominatorRange = props.denominatorRange;
this.numeratorRange = props.numeratorRange;
this.offsetFromLine = props.offsetFromLine;
this.style = props.style;
this.fontId = props.fontId;
}
static create(source, parentParagraph, context) {
const style = context.textStyleResolver.resolveRunSettings(parentParagraph, source);
const fontId = context.findFontId(style.fontName);
const charOffset = 0;
const offsetFromLine = { x: 0, y: 0 };
let numChars = 0;
let range, justificationRange, numeratorRange, denominatorRange;
switch (source.type) {
case "field":
case "text": {
const content = source.type === "text" ? source.content : source.cachedContent;
numChars = content.length;
const ranges = context.computeRangeForTextRun(style, source, charOffset, numChars);
range = ranges.layout;
justificationRange = ranges.justification;
break;
}
case "fraction": {
numChars = 1;
const ranges = context.computeRangeForFractionRun(style, source);
range = ranges.layout;
numeratorRange = ranges.numerator;
denominatorRange = ranges.denominator;
break;
}
default: { // "linebreak" or "tab"
// "tab": Tabs rely on the context they are in, so we compute its range later.
// lineBreak: We do this so that blank lines space correctly without special casing later.
range = new core_geometry_1.Range2d(0, 0, 0, style.lineHeight);
break;
}
}
return new RunLayout({ source, charOffset, numChars, range, justificationRange, denominatorRange, numeratorRange, offsetFromLine, style, fontId });
}
/** Compute a string representation, primarily for debugging purposes. */
stringify() {
return this.source.type === "text" ? this.source.content.substring(this.charOffset, this.charOffset + this.numChars) : this.source.stringify();
}
canWrap() {
return this.source.type === "text";
}
cloneForWrap(args) {
(0, core_bentley_1.assert)(this.canWrap());
return new RunLayout({
...this,
charOffset: args.charOffset,
numChars: args.numChars,
range: args.ranges.layout,
justificationRange: args.ranges.justification,
offsetFromLine: { ...this.offsetFromLine },
});
}
split(context) {
(0, core_bentley_1.assert)(this.charOffset === 0, "cannot re-split a run");
if (!this.canWrap() || this.charOffset > 0) {
return [this];
}
const myText = this.source.content.substring(this.charOffset, this.charOffset + this.numChars);
const segments = split(myText);
if (segments.length <= 1) {
return [this];
}
return segments.map((segment) => {
return this.cloneForWrap({
ranges: context.computeRangeForText(segment.segment, this.style, this.source.baselineShift),
charOffset: segment.index,
numChars: segment.segment.length,
});
});
}
toResult(paragraph) {
const result = {
sourceRunIndex: paragraph.runs.indexOf(this.source),
fontId: this.fontId,
characterOffset: this.charOffset,
characterCount: this.numChars,
range: this.range.toJSON(),
offsetFromLine: this.offsetFromLine,
textStyle: this.style.toJSON(),
};
if (this.justificationRange) {
result.justificationRange = this.justificationRange.toJSON();
}
if (this.numeratorRange) {
result.numeratorRange = this.numeratorRange.toJSON();
}
if (this.denominatorRange) {
result.denominatorRange = this.denominatorRange.toJSON();
}
return result;
}
}
exports.RunLayout = RunLayout;
/**
* Represents the layout of a single line within a paragraph of a text block.
* Contains a sequence of RunLayout objects, the computed range of the line, and its offset from the document origin.
* Provides utilities for appending runs, computing ranges, and converting to result objects.
* @beta
*/
class LineLayout {
source;
range = new core_geometry_1.Range2d(0, 0, 0, 0);
justificationRange = new core_geometry_1.Range2d(0, 0, 0, 0);
offsetFromDocument = { x: 0, y: 0 };
lengthFromLastTab = 0; // Used to track the length from the last tab for tab runs.
_runs = [];
constructor(source) {
this.source = source;
}
/** Compute a string representation, primarily for debugging purposes. */
stringify() {
const runs = this._runs.map((run) => run.stringify());
return `${runs.join("")}`;
}
get runs() { return this._runs; }
get isEmpty() { return this._runs.length === 0; }
get back() {
(0, core_bentley_1.assert)(!this.isEmpty);
return this._runs[this._runs.length - 1];
}
append(run) {
this._runs.push(run);
this.computeRanges();
}
/** Invoked every time a run is appended,. */
computeRanges() {
this.range.low.setZero();
this.range.high.setZero();
// Some runs (fractions) are taller than others.
// We want to center each run vertically inside the line.
let lineHeight = 0;
for (const run of this._runs) {
lineHeight = Math.max(lineHeight, run.range.yLength());
}
for (const run of this._runs) {
const runHeight = run.range.yLength();
const runOffset = { x: this.range.high.x, y: (lineHeight - runHeight) / 2 };
run.offsetFromLine = runOffset;
const runLayoutRange = run.range.cloneTranslated(runOffset);
this.range.extendRange(runLayoutRange);
if ("linebreak" !== run.source.type) {
const runJustificationRange = run.justificationRange?.cloneTranslated(runOffset);
this.justificationRange.extendRange(runJustificationRange ?? runLayoutRange);
}
if (run.source.type === "tab") {
this.lengthFromLastTab = 0;
}
else {
this.lengthFromLastTab += run.range.xLength();
}
}
}
toResult(textBlock) {
return {
sourceParagraphIndex: textBlock.paragraphs.indexOf(this.source),
runs: this.runs.map((x) => x.toResult(this.source)),
range: this.range.toJSON(),
justificationRange: this.justificationRange.toJSON(),
offsetFromDocument: this.offsetFromDocument,
};
}
}
exports.LineLayout = LineLayout;
/**
* Describes the layout of a text block as a collection of lines containing runs.
* Computes the visual layout of the text block, including word wrapping, justification, and margins.
* Provides access to the computed lines, ranges, and utilities for converting to result objects.
* @beta
*/
class TextBlockLayout {
source;
/** @internal: This is primarily for debugging purposes. This is the range of text geometry */
textRange = new core_geometry_1.Range2d();
/** The range including margins of the [[TextBlock]]. */
range = new core_geometry_1.Range2d();
lines = [];
_context;
constructor(source, context) {
this._context = context;
this.source = source;
if (source.width > 0) {
this.textRange.low.x = 0;
this.textRange.high.x = source.width;
}
this.populateLines(context);
this.justifyLines();
this.applyMargins(source.margins);
}
toResult() {
return {
lines: this.lines.map((x) => x.toResult(this.source)),
range: this.range.toJSON(),
};
}
/** Compute a string representation, primarily for debugging purposes. */
stringify() {
return this.lines.map((line) => line.stringify()).join("\n");
}
get _back() {
(0, core_bentley_1.assert)(this.lines.length > 0);
return this.lines[this.lines.length - 1];
}
populateLines(context) {
const doc = this.source;
if (doc.paragraphs.length === 0) {
return;
}
const doWrap = doc.width > 0;
let curLine = new LineLayout(doc.paragraphs[0]);
for (let i = 0; i < doc.paragraphs.length; i++) {
const paragraph = doc.paragraphs[i];
if (i > 0) {
curLine = this.flushLine(context, curLine, paragraph);
}
let runs = paragraph.runs.map((run) => RunLayout.create(run, paragraph, context));
if (doWrap) {
runs = runs.map((run) => run.split(context)).flat();
}
for (const run of runs) {
if ("linebreak" === run.source.type) {
curLine.append(run);
curLine = this.flushLine(context, curLine);
continue;
}
// If this is a tab, we need to apply the tab shift first, and then we can treat it like a text run.
applyTabShift(run, curLine, context);
// If our width is not set (doWrap is false), then we don't have to compute word wrapping, so just append the run, and continue.
if (!doWrap) {
curLine.append(run);
continue;
}
// Next, determine if we can append this run to the current line without exceeding the document width
const runWidth = run.range.xLength();
const lineWidth = curLine.range.xLength();
// If true, then no word wrapping is required, so we can append to the current line.
if (runWidth + lineWidth < doc.width || core_geometry_1.Geometry.isAlmostEqualNumber(runWidth + lineWidth, doc.width, core_geometry_1.Geometry.smallMetricDistance)) {
curLine.append(run);
continue;
}
// Do word wrapping
if (curLine.runs.length === 0) {
curLine.append(run);
// Lastly, flush line
curLine = this.flushLine(context, curLine);
}
else {
// First, flush line
curLine = this.flushLine(context, curLine);
// Recompute tab shift if applicable
applyTabShift(run, curLine, context);
curLine.append(run);
}
}
}
if (curLine.runs.length > 0) {
this.flushLine(context, curLine);
}
}
justifyLines() {
// We don't want to justify empty text, or a single line of text whose width is 0. By default text is already left justified.
if (this.lines.length < 1 || (this.lines.length === 1 && this.source.width === 0) || "left" === this.source.justification) {
return;
}
// This is the minimum width of the document's bounding box.
const docWidth = this.source.width;
let minOffset = Number.MAX_VALUE;
for (const line of this.lines) {
const lineWidth = line.justificationRange.xLength();
let offset = docWidth - lineWidth;
if ("center" === this.source.justification) {
offset = offset / 2;
}
line.offsetFromDocument.x += offset;
minOffset = Math.min(offset, minOffset);
}
if (minOffset < 0) {
// Shift left to accommodate lines that exceeded the document's minimum width.
this.textRange.low.x += minOffset;
this.textRange.high.x += minOffset;
}
}
flushLine(context, line, nextParagraph) {
nextParagraph = nextParagraph ?? line.source;
// We want to guarantee that each layout line has at least one run.
if (line.runs.length === 0) {
// If we're empty, there should always be a preceding run, and it should be a line break.
if (this.lines.length === 0 || this._back.runs.length === 0) {
return new LineLayout(nextParagraph);
}
const prevRun = this._back.back.source;
(0, core_bentley_1.assert)(prevRun.type === "linebreak");
if (prevRun.type !== "linebreak") {
return new LineLayout(nextParagraph);
}
line.append(RunLayout.create(prevRun.clone(), line.source, context));
}
// Line origin is its baseline.
const lineOffset = { x: 0, y: -line.range.yLength() };
// Place it below any existing lines
if (this.lines.length > 0) {
lineOffset.y += this._back.offsetFromDocument.y;
lineOffset.y -= context.textStyleResolver.blockSettings.lineSpacingFactor * context.textStyleResolver.blockSettings.lineHeight;
}
line.offsetFromDocument = lineOffset;
// Update document range from computed line range and position
this.textRange.extendRange(line.range.cloneTranslated(lineOffset));
this.lines.push(line);
return new LineLayout(nextParagraph);
}
applyMargins(margins) {
this.range = this.textRange.clone();
if (this.range.isNull)
return;
// Disregard negative margins.
const right = margins.right >= 0 ? margins.right : 0;
const left = margins.left >= 0 ? margins.left : 0;
const top = margins.top >= 0 ? margins.top : 0;
const bottom = margins.bottom >= 0 ? margins.bottom : 0;
const xHigh = this.textRange.high.x + right;
const yHigh = this.textRange.high.y + top;
const xLow = this.textRange.low.x - left;
const yLow = this.textRange.low.y - bottom;
this.range.extendXY(xHigh, yHigh);
this.range.extendXY(xLow, yLow);
}
}
exports.TextBlockLayout = TextBlockLayout;
//# sourceMappingURL=TextBlockLayout.js.map