UNPKG

@itwin/core-backend

Version:
647 lines • 28.1 kB
"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