UNPKG

@itwin/core-backend

Version:
522 lines • 22 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 = 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"); /** * 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]] * @internal */ 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)); // ###TODO finding text styles in workspaces. const findTextStyle = args.findTextStyle ?? (() => core_common_1.TextStyleSettings.fromJSON()); return new TextBlockLayout(args.textBlock, new LayoutContext(args.textBlock, computeTextRange, findTextStyle, 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 [TextStyle]($common)s, fonts, and [TextBlock.width]($common). It applies word-wrapping if needed. * The layout returned matches the visual layout of the geometry produced by [[produceTextAnnotationGeometry]]. * @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 findTextStyle = args.findTextStyle ?? (() => core_common_1.TextStyleSettings.fromJSON()); 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(textBlock, computeTextRange, findTextStyle, 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); } function applyBlockSettings(target, source) { if (source === target) { return target; } const lineSpacingFactor = source.lineSpacingFactor ?? target.lineSpacingFactor; const lineHeight = source.lineHeight ?? target.lineHeight; const widthFactor = source.widthFactor ?? target.widthFactor; if (lineSpacingFactor !== target.lineSpacingFactor || lineHeight !== target.lineHeight || widthFactor !== target.widthFactor) { target = target.clone({ lineSpacingFactor, lineHeight, widthFactor }); } return target; } class LayoutContext { _computeTextRange; _findTextStyle; _findFontId; _textStyles = new Map(); _fontIds = new Map(); blockSettings; constructor(block, _computeTextRange, _findTextStyle, _findFontId) { this._computeTextRange = _computeTextRange; this._findTextStyle = _findTextStyle; this._findFontId = _findFontId; const settings = this.findTextStyle(block.styleName); this.blockSettings = applyBlockSettings(settings, block.styleOverrides); } findFontId(name) { let fontId = this._fontIds.get(name); if (undefined === fontId) { this._fontIds.set(name, fontId = this._findFontId(name)); } return fontId; } findTextStyle(name) { let style = this._textStyles.get(name); if (undefined === style) { this._textStyles.set(name, style = this._findTextStyle(name)); } return style; } createRunSettings(run) { let settings = this.findTextStyle(run.styleName); if (run.overridesStyle) { settings = settings.clone(run.styleOverrides); } return applyBlockSettings(settings, this.blockSettings); } 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.blockSettings.lineHeight, widthFactor: this.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) { return this.computeRangeForText(run.content.substring(charOffset, charOffset + numChars), style, run.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 }; } } 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; } /** @internal */ 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, context) { const style = context.createRunSettings(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 "text": { numChars = source.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: { // 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; /** @internal */ 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 }; _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); } } } 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. * @internal */ 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.textRange.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, 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 (!doWrap) { curLine.append(run); continue; } const runWidth = run.range.xLength(); const lineWidth = curLine.range.xLength(); if (runWidth + lineWidth < doc.width || core_geometry_1.Geometry.isAlmostEqualNumber(runWidth + lineWidth, doc.width, core_geometry_1.Geometry.smallMetricDistance)) { curLine.append(run); continue; } if (curLine.runs.length === 0) { curLine.append(run); curLine = this.flushLine(context, curLine); } else { curLine = this.flushLine(context, curLine); 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(), 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.blockSettings.lineSpacingFactor * context.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=TextAnnotationLayout.js.map