UNPKG

scrawl-canvas

Version:

Responsive, interactive and more accessible HTML5 canvas elements. Scrawl-canvas is a JavaScript library designed to make using the HTML5 canvas element easier, and more fun

1,526 lines (1,112 loc) 133 kB
// # EnhancedLabel factory // TODO - document purpose and description // // To note: EnhancedLabel entitys will, if told to, break words across lines on hard (- U+2010) and soft (&shy U+00AD) hyphens. It makes no effort to guess whether a word _can_ be broken at a given place, regardless of any [CSS settings for the web page/component](https://css-tricks.com/almanac/properties/h/hyphenate/) in which the SC canvas finds itself. For that sort of functionality, use a third party library like [Hyphenopoly](https://github.com/mnater/Hyphenopoly) to pre-process text before feeding it into the entity. // #### Imports import { artefact, asset, constructors, group, tween } from '../core/library.js'; import { makeState } from '../untracked-factory/state.js'; import { makeTextStyle } from '../untracked-factory/text-style.js'; import { makeCoordinate } from '../untracked-factory/coordinate.js'; import { currentGroup } from './canvas.js'; import { filterEngine } from '../helper/filter-engine.js'; import { importDomImage } from '../asset-management/image-asset.js'; import { releaseCell, requestCell } from '../untracked-factory/cell-fragment.js'; import { releaseCoordinate, requestCoordinate } from '../untracked-factory/coordinate.js'; import { releaseArray, requestArray } from '../helper/array-pool.js'; import baseMix from '../mixin/base.js'; import deltaMix from '../mixin/delta.js'; import filterMix from '../mixin/filter.js'; import textMix from '../mixin/text.js'; import { doCreate, isa_fn, isa_obj, mergeOver, pushUnique, removeItem, xta, λnull, Ωempty } from '../helper/utilities.js'; // Shared constants import { _abs, _assign, _ceil, _computed, _cos, _create, _entries, _floor, _hypot, _isArray, _isFinite, _keys, _radian, _round, _setPrototypeOf, _sin, _values, ALPHABETIC, AUTO, BOTTOM, CENTER, DESTINATION_OVER, END, ENTITY, FILL, GOOD_HOST, HANGING, IDEOGRAPHIC, IMG, LEFT, LTR, MIDDLE, NONE, NORMAL, PX0, RIGHT, ROUND, SOURCE_IN, SOURCE_OUT, SOURCE_OVER, SPACE, START, T_CELL, T_ENHANCED_LABEL, T_GROUP, TOP, ZERO_STR } from '../helper/shared-vars.js'; // Local constants const DRAW = 'draw', DRAW_AND_FILL = 'drawAndFill', FILL_AND_DRAW = 'fillAndDraw', FONT_VIEWPORT_LENGTH_REGEX = /[0-9.,]+(svh|lvh|dvh|vh|svw|lvw|dvw|vw|svmax|lvmax|dvmax|vmax|svmin|lvmin|dvmin|vmin|svb|lvb|dvb|vb|svi|lvi|dvi|vi)/i, FORCE = 'force', OFF = 'off', ROW = 'row', SOFT = 'soft', SPACE_AROUND = 'space-around', SPACE_BETWEEN = 'space-between', T_ENHANCED_LABEL_LINE = 'EnhancedLabelLine', T_ENHANCED_LABEL_UNIT = 'EnhancedLabelUnit', T_ENHANCED_LABEL_UNITARRAY = 'EnhancedLabelUnitArray', TEXT_HARD_HYPHEN_REGEX = /[-]/, TEXT_LAYOUT_FLOW_COLUMNS = ['column', 'column-reverse'], TEXT_LAYOUT_FLOW_REVERSE = ['row-reverse', 'column-reverse'], TEXT_NO_BREAK_REGEX = /[\u2060]/, TEXT_SOFT_HYPHEN_REGEX = /[\u00ad]/, WORD = 'word', ZWSP = 'zwsp'; // Excludes \u00A0 (no-break-space) and includes \u200b const TEXT_SPACES_REGEX = /[ \f\n\r\t\v\u2028\u2029\u200b]/, TEXT_TYPE_CHARS = 'C', TEXT_TYPE_HYPHEN = 'H', TEXT_TYPE_NO_BREAK = 'B', TEXT_TYPE_SOFT_HYPHEN = 'h', TEXT_TYPE_SPACE = 'S', TEXT_TYPE_ZERO_SPACE = 'Z', TEXT_TYPE_TRUNCATE = 'T', TEXT_ZERO_SPACE_REGEX = /[\u200b]/, THAI_REGEX = /[\u0E00-\u0E7F]/, LAO_REGEX = /[\u0E80-\u0EFF]/, KHMER_REGEX = /[\u1780-\u17FF\u19E0-\u19FF]/, MYANMAR_REGEX = /[\u1000-\u109F\uAA60-\uAA7F\uA9E0-\uA9FF]/, CJK_CLOSE_RE = /[、。.,:;!?〕〉》」』】])]/, CJK_OPEN_RE = /[〔〈《「『【[(]/, WORD_JOINER = '\u2060', LOOKS_CJK_RE = /[\u3000-\u303F\u3040-\u30FF\u3400-\u9FFF\uF900-\uFAFF]/, TEXT_LOOKS_CJK_REGEX = /[\u3000-\u303F\u3040-\u30FF\u3400-\u9FFF\uF900-\uFAFF\u2E80-\u2EFF]/; // Decide if a short string looks "word-like" when Segmenter doesn't provide isWordLike const IS_WORDLIKE = (s) => /[\p{L}\p{N}]/u.test(s), ISWORDLIKE = 'isWordLike'; // Basic BCP-47-ish sanity check (lightweight; avoids obviously bad tags) const LANG_TAG_REGEX = /^[a-z]{2,3}(?:-[A-Za-z0-9]{2,8})*$/i; // Horizontal → vertical presentation form map (most common marks) const VERTICAL_PUNCT_MAP = new Map([ [',', '\uFE10'], ['、', '\uFE11'], ['。', '\uFE12'], [':', '\uFE13'], [';', '\uFE14'], ['!', '\uFE15'], ['?', '\uFE16'], ['…', '\uFE19'], ['—', '\uFE31'], ['(', '\uFE35'], [')', '\uFE36'], ['{', '\uFE37'], ['}', '\uFE38'], ['〔', '\uFE39'], ['〕', '\uFE3A'], ['【', '\uFE3B'], ['】', '\uFE3C'], ['《', '\uFE3D'], ['》', '\uFE3E'], ['〈', '\uFE3F'], ['〉', '\uFE40'], ['「', '\uFE41'], ['」', '\uFE42'], ['『', '\uFE43'], ['』', '\uFE44'], ['[', '\uFE47'], [']', '\uFE48'], ]); const toVerticalCjkForms = (s) => { let out = '', i, iz, ch; for (i = 0, iz = s.length; i < iz; i++) { ch = s[i]; out += VERTICAL_PUNCT_MAP.get(ch) || ch; } return out; }; const autoBindCjkPunctuation = (text) => { const len = text.length; let out = '', i, ch, next; for (i = 0; i < len; i++) { ch = text[i]; next = i + 1 < len ? text[i + 1] : ''; out += ch; // 1) Prevent break AFTER openers: insert joiner after the opener if (CJK_OPEN_RE.test(ch) && next && next !== WORD_JOINER) { out += WORD_JOINER; continue; } // 2) Prevent break BEFORE closers: insert joiner before the closer if (next && CJK_CLOSE_RE.test(next) && ch !== WORD_JOINER) { out += WORD_JOINER; } } return out; }; // #### EnhancedLabel constructor const EnhancedLabel = function (items = Ωempty) { this.makeName(items.name); this.register(); this.state = makeState(Ωempty); this.defaultTextStyle = makeTextStyle({ isDefaultTextStyle: true, }); this.cache = null; this.textUnitHitZones = []; this.pivoted = []; this.set(this.defs); if (!items.group) items.group = currentGroup; this.currentFontIsLoaded = false; this.updateUsingFontParts = false; this.updateUsingFontString = false; this.usingViewportFontSizing = false; this.useMimicDimensions = true; this.useMimicFlip = true; this.useMimicHandle = true; this.useMimicOffset = true; this.useMimicRotation = true; this.useMimicScale = true; this.useMimicStart = true; this.delta = {}; this.deltaConstraints = {}; this.currentStampPosition = makeCoordinate(); this.textHandle = makeCoordinate(); this.textOffset = makeCoordinate(); this.lines = []; this.textUnits = makeTextUnitArray(); this.underlinePaths = []; this.overlinePaths = []; this.highlightPaths = []; this.guidelineDash = []; this.dirtyStart = true; this.dirtyHandle = true; this.dirtyOffset = true; this.dirtyRotation = true; this.dirtyScale = true; this.dirtyDimensions = true; this.currentHost = null; this.dirtyHost = true; this.currentDimensions = []; this.currentScale = 1; this.currentStampHandlePosition = null; this.pathObject = null; this.accessibleTextHold = null; this.accessibleTextHoldAttached = null; this.guidelinesPath = null; this.currentPathData = null; this.filters = []; this.currentFilters = []; this.dirtyFilters = true; this.dirtyFiltersCache = true; this.dirtyImageSubscribers = true; this.stashOutput = false; this.stashOutputAsAsset = false; this.stashedImageData = null; this.stashedImage = null; this.set(items); this.dirtyFont = true; this.currentFontIsLoaded = false; return this; }; // #### EnhancedLabel prototype const P = EnhancedLabel.prototype = doCreate(); P.type = T_ENHANCED_LABEL; P.lib = ENTITY; P.isArtefact = true; P.isAsset = false; // #### Mixins baseMix(P); deltaMix(P); filterMix(P); textMix(P); // #### EnhancedLabel attributes const defaultAttributes = { // __text__ - string. // + Can include html/css styling data text: ZERO_STR, // __lineSpacing__ - number. The distance between lines of text, as a ratio of the default font height // + Can be set/deltaSet in the normal way // + Alternatively, can be set via the fontString attribute. // + Default value is set to `1.5` for accessibility reasons lineSpacing: 1.5, // __layoutTemplate__ - artefact object, or artefact's string name attribute. layoutTemplate: null, // __useLayoutTemplateAsPath__ - boolean. If layout engine entity is a path-based entity, then we can either fit the text within it, or use its path for positioning. useLayoutTemplateAsPath: false, // __pathPosition__ - number. Where to start text positioning along the layout engine path. pathPosition: 0, constantSpeedAlongPath: true, // __alignment__ - number. Rotational positioning of the text units along a path or guideline alignment: 0, // __alignTextUnitsToPath__ - boolean. Forces layout to take into account the path angle. When set to false, all text units will have the same alignment, whose value is set by the `alignment` attribute alignTextUnitsToPath: true, // __lineAdjustment__ - number. Determines the fine-scale positioning of the guidelines within a space lineAdjustment: 0, // __breakTextOnSpaces__ - boolean. // + When `true` (default), the textUnits will consist of words which are stamped as a unit (which preserves ligatures and kerning within the word). // + Set this attribute to `false` if the font's language, when written, (generally) doesn't include spaces (eg: Chinese, Japanese), or when there is a requirement to style individual characters within words breakTextOnSpaces: true, // __breakWordsOnHyphens__ - boolean. // + When `true`, words that include hard or soft hyphens will be split into separate units for processing. Be aware that in highly ligatured fonts this may cause problems. The attribute defaults to `false`. // + It is possible to style individual characters in a text that breaks on spaces by adding soft hyphens before and after the characters, but it may (will) lead to unnatural-looking word breaks at the end of the line. // + Attribute has no effect if `breakTextOnSpaces` is `false`. breakWordsOnHyphens: false, // __justifyLine__ - string enum. Allowed values are 'start', 'end', 'center' (default), 'space-between', 'space-around' // + Determines the positioning of text units along the space layout line. Has nothing to do with the `direction` attribute. justifyLine: CENTER, // __textUnitFlow__ - string enum. Allowed values are 'row' (default), 'row-reverse', 'column' (for vertical text), 'column-reverse' // + Determines the ordering of text units along the space layout line. Has nothing to do with the `direction` attribute. textUnitFlow: ROW, // __startTextOnLine__ - positive integer number. Default: `0` // + Determines on which line the text layout will start. startTextOnLine: 0, // __autoHyphenate__ – boolean flag to opt in to language-aware word breaking and auto-hyphenation. When true, the text is pre-processed before layout using either the user-defined `lineBreakHook` or the browser’s built-in [Intl.Segmenter](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/Segmenter). // + Inserts either `\u200B` (zero-width space) or `\u00AD` (soft hyphen) between word-like segments, depending on the value of `lineBreakInsert`. autoHyphenate: false, // __language__ – indicates the language of the text displayed by the EnhancedLabel entity. Used to choose appropriate word-break and soft-hyphen behavior. // + Accepts a BCP-47 language tag such as `'th'`, `'en'`, `'ja'`, etc. // + When set to `'auto'` (default), Scrawl-canvas attempts a simple heuristic detection (for example, Thai script detection via Unicode range). language: AUTO, // __lineBreakInsert__ – specifies which invisible marker to insert at potential break points. Options are `'zwsp'` (zero-width space) or `'soft'` (soft hyphen). Default: `'zwsp'`. lineBreakInsert: ZWSP, // __lineBreakHook__ – optional callback function providing custom word-breaking or hyphenation logic. The function signature is `(text, lang) => string | string[]`. // + If a string is returned, it is used directly as the processed text. // + If an array is returned, its elements are joined with the appropriate break character. // + This allows integration with external, professional hyphenation or language analysis tools. lineBreakHook: null, // __cjkPunctuationBinding__ - keep CJK punctuation tied to the preceding or following character (as appropriate) rather than fall onto the next line (or end the previous line). // + `'off'`, `'auto'` (default, detect CJK in text), `'force'` (always) cjkPunctuationBinding: AUTO, // __verticalCjkPunctuation__ - Use vertical presentation forms for CJK punctuation when text flows in columns. // + `'off'`, `'auto'` (default, detect CJK in text), `'force'` (always, in column flow) verticalCjkPunctuation: AUTO, // __truncateString__ - string. truncateString: '…', // __hyphenString__ - string. hyphenString: '-', // __textHandle__ - Coordinate. textHandle: null, textOffset: null, // __showGuidelines__ - boolean. showGuidelines: false, guidelineStyle: 'rgb(0 0 0 / 0.5)', guidelineWidth: 1, guidelineDash: null, // The EnhancedLabel entity does not use the [position](./mixin/position.html) or [entity](./mixin/entity.html) mixins (used by most other entitys) as its positioning is entirely dependent on the position, rotation, scale etc of its constituent Shape path entity struts. // // It does, however, use these attributes (alongside their setters and getters): __visibility__, __order__, __delta__, __host__, __group__, __anchor__. visibility: true, calculateOrder: 0, stampOrder: 0, host: null, group: null, method: FILL, lockFillStyleToEntity: false, lockStrokeStyleToEntity: false, cacheOutput: true, checkHitUseTemplate: true, }; P.defs = mergeOver(P.defs, defaultAttributes); // #### Packet management P.packetExclusions = pushUnique(P.packetExclusions, ['pathObject', 'mimicked', 'pivoted', 'state']); P.packetExclusionsByRegex = pushUnique(P.packetExclusionsByRegex, ['^(local|dirty|current)', 'Subscriber$']); P.packetCoordinates = pushUnique(P.packetCoordinates, ['start', 'handle', 'offset']); P.packetObjects = pushUnique(P.packetObjects, ['group', 'layoutTemplate']); P.packetFunctions = pushUnique(P.packetFunctions, ['onEnter', 'onLeave', 'onDown', 'onUp']); P.processPacketOut = function (key, value, inc) { return this.processEntityPacketOut(key, value, inc); }; // handles both anchor and button objects P.handlePacketAnchor = function (copy) { return copy; } P.processEntityPacketOut = function (key, value, incs) { return this.processFactoryPacketOut(key, value, incs); }; P.processFactoryPacketOut = function (key, value, incs) { let result = true; if(!incs.includes(key) && value === this.defs[key]) result = false; return result; }; // #### Clone management P.postCloneAction = function(clone) { return clone; }; // #### Kill management P.kill = function (flag1 = false, flag2 = false) { const name = this.name; // Remove artefact from all groups _values(group).forEach(val => { if (val.artefacts.includes(name)) val.removeArtefacts(name); }); // If the artefact has an anchor, it needs to be removed if (this.anchor) this.demolishAnchor(); // If the artefact has a button, it needs to be removed if (this.button) this.demolishButton(); // Remove from other artefacts _values(artefact).forEach(val => { if (val.name !== name) { if (val.pivot && val.pivot.name === name) val.set({ pivot: false}); if (val.mimic && val.mimic.name === name) val.set({ mimic: false}); if (val.path && val.path.name === name) val.set({ path: false}); if (val.generateAlongPath && val.generateAlongPath.name === name) val.set({ generateAlongPath: false}); if (val.generateInArea && val.generateInArea.name === name) val.set({ generateInArea: false}); if (val.artefact && val.artefact.name === name) val.set({ artefact: false}); if (_isArray(val.pins)) { val.pins.forEach((item, index) => { if (isa_obj(item) && item.name === name) val.removePinAt(index); }); } } }); // Remove from tweens and actions targets arrays _values(tween).forEach(val => { if (val.checkForTarget(name)) val.removeFromTargets(this); }); // Factory-specific actions required to complete the kill this.factoryKill(flag1, flag2); // Remove artefact from the Scrawl-canvas library this.deregister(); return this; }; // #### Get, Set, deltaSet const G = P.getters, S = P.setters, D = P.deltaSetters; // __group__ - copied over from the position mixin. G.group = function () { return (this.group) ? this.group.name : ZERO_STR; }; S.group = function (item) { let g; if (item) { if (this.group && this.group.type === T_GROUP) this.group.removeArtefacts(this.name); if (item.substring) { g = group[item]; if (g) this.group = g; else this.group = item; } else this.group = item; } if (this.group && this.group.type === T_GROUP) this.group.addArtefacts(this.name); }; // __layoutTemplate__ - TODO: documentation S.layoutTemplate = function (item) { if (item) { const oldTemplate = this.layoutTemplate, newTemplate = (item.substring) ? artefact[item] : item, name = this.name; if (newTemplate && newTemplate.name) { if (oldTemplate && oldTemplate.name !== newTemplate.name) { if (oldTemplate.mimicked) removeItem(oldTemplate.mimicked, name); if (oldTemplate.pathed) removeItem(oldTemplate.pathed, name); } if (newTemplate.mimicked) pushUnique(newTemplate.mimicked, name); if (newTemplate.pathed) pushUnique(newTemplate.pathed, name); this.layoutTemplate = newTemplate; this.dirtyPathObject = true; this.dirtyLayout = true; } } }; S.breakTextOnSpaces = function (item) { this.breakTextOnSpaces = !!item; this.dirtyText = true; }; S.breakWordsOnHyphens = function (item) { this.breakWordsOnHyphens = !!item; this.dirtyText = true; }; S.autoHyphenate = function (item) { this.autoHyphenate = !!item; this.dirtyText = true; }; S.language = function (item) { // Accept BCP-47 tag or AUTO constant this.language = (item && item.substring) ? item : AUTO; this.dirtyText = true; }; S.lineBreakInsert = function (item) { // only 'zwsp' or 'soft' this.lineBreakInsert = (item === SOFT) ? SOFT : ZWSP; this.dirtyText = true; }; S.lineBreakHook = function (fn) { this.lineBreakHook = (isa_fn(fn)) ? fn : null; this.dirtyText = true; }; // For the CJK punctuation option if you expose it in UI S.verticalCjkPunctuation = function (item) { const v = (item === FORCE || item === OFF) ? item : AUTO; this.verticalCjkPunctuation = v; this.dirtyText = true; }; S.cjkPunctuationBinding = function (item) { const v = (item === FORCE || item === OFF) ? item : AUTO; this.cjkPunctuationBinding = v; this.dirtyText = true; }; S.truncateString = function (item) { if (item.substring) { this.truncateString = this.convertTextEntityCharacters(item); this.dirtyText = true; } }; S.hyphenString = function (item) { if (item.substring) { this.hyphenString = this.convertTextEntityCharacters(item); this.dirtyText = true; } }; S.textHandleX = function (item) { this.textHandle[0] = item; this.dirtyLayout = true; }; S.textHandleY = function (item) { this.textHandle[1] = item; this.dirtyLayout = true; }; S.textHandle = function (item) { if (_isArray(item) && item.length > 1) { this.textHandle[0] = item[0]; this.textHandle[1] = item[1]; this.dirtyLayout = true; } }; S.guidelineDash = function (item) { if (_isArray(item)) this.guidelineDash = item; }; S.guidelineStyle = function (item) { if (!item) this.guidelineStyle = this.defs.guidelineStyle; else if (item.substring) this.guidelineStyle = item; }; S.pathPosition = function (item) { if (item < 0) item = _abs(item); if (item > 1) item = item % 1; this.pathPosition = parseFloat(item.toFixed(6)); this.dirtyTextLayout = true; }; D.pathPosition = function (item) { let pos = this.pathPosition + item if (pos < 0) pos += 1; if (pos > 1) pos = pos % 1; this.pathPosition = parseFloat(pos.toFixed(6)); this.dirtyTextLayout = true; }; S.textUnitFlow = function (item) { this.textUnitFlow = item; this.dirtyText = true; }; G.textUnits = function () { return this.textUnits; }; G.textLines = function () { return this.lines; }; // #### Prototype functions // `getTester` - Retrieve the DOM labelStylesCalculator &lt;div> element P.getTester = function () { const controller = this.getControllerCell(); if (controller) return controller.labelStylesCalculator; return null; }; // `makeWorkingTextStyle` - Clone a TextStyle object P.makeWorkingTextStyle = function (template) { const workStyle = _create(template); _assign(workStyle, template); workStyle.isDefaultTextStyle = false; return workStyle; }; // `setEngineFromWorkingTextStyle` - Sets the state object to current working requirements, alongside directly updating the Cell's engine to match P.setEngineFromWorkingTextStyle = function (worker, style, state, cell) { this.updateWorkingTextStyle(worker, style); state.set(worker); cell.setEngine(this); }; // `updateWorkingTextStyle` - Updates the working TextStyle object with a partial TextStyle object, and regenerates font strings from the updated data // + Takes into account the layout entity's current scaling factor P.updateWorkingTextStyle = function (worker, style) { let scale = 1; if (this.layoutTemplate) scale = this.layoutTemplate.currentScale; worker.set(style, true); this.updateCanvasFont(worker, scale); this.updateFontString(worker); }; // `getTextHandleX` - Calculate the horizontal offset required for a given TextUnit P.getTextHandleX = function (val, dim, dir) { if (val.toFixed) return val; if (val === START) return (dir === LTR) ? 0 : dim; if (val === CENTER) return dim / 2; if (val === END) return (dir === LTR) ? dim : 0; if (val === LEFT) return 0; if (val === RIGHT) return dim; if (!_isFinite(parseFloat(val))) return 0; return (parseFloat(val) / 100) * dim; }; // `getTextHandleY` - Calculate the vertical offset required for a given TextUnit P.getTextHandleY = function (val, size, font) { const meta = this.getFontMetadata(font); const { alphabeticRatio, hangingRatio, height, ideographicRatio, } = meta; const ratio = size / 100; let scale = 1; if (this.layoutTemplate) scale = this.layoutTemplate.currentScale; const dim = height * ratio; if (val.toFixed) return val * scale; if (val === TOP) return 0; if (val === BOTTOM) return dim * scale; if (val === CENTER) return (dim / 2) * scale; if (val === ALPHABETIC) return dim * alphabeticRatio * scale; if (val === HANGING) return dim * hangingRatio * scale; if (val === IDEOGRAPHIC) return dim * ideographicRatio * scale; if (val === MIDDLE) return (dim / 2) * scale; if (!_isFinite(parseFloat(val))) return 0; return (parseFloat(val) / 100) * dim * scale; }; // `getTextOffset` - Calculate the horizontal offset required for a given TextUnit P.getTextOffset = function (val, dim) { if (val.toFixed) return val; if (!_isFinite(parseFloat(val))) return 0; return (parseFloat(val) / 100) * dim; }; P.dirtyCache = function () { releaseCell(this.cache); this.cache = null; this.textUnitHitZones.length = 0; if (this.pivoted.length) this.updatePivotSubscribers(); }; // #### Clean functions // `cleanPathObject` - calculate the EnhancedLabel entity's __Path2D object__ P.cleanPathObject = function () { const layout = this.layoutTemplate; if (layout && this.dirtyPathObject && layout.pathObject) { this.dirtyPathObject = false; this.pathObject = new Path2D(layout.pathObject); } }; // `cleanLayout` - recalculate the positioning of all TextUnits in the space or along the path P.cleanLayout = function () { if (this.currentFontIsLoaded) { this.dirtyCache(); this.dirtyLayout = false; if (!this.useLayoutTemplateAsPath) this.calculateLines(); this.dirtyTextLayout = true; } }; // `calculateLines` - calculate the positions and lengths of multiple lines withing a layout entity's enclosed space. P.calculateLines = function () { const { alignment, defaultTextStyle, layoutTemplate, lineAdjustment, lines, lineSpacing, textUnitFlow, } = this; const { currentDimensions, currentScale, currentRotation, currentStampPosition, pathObject, winding, } = layoutTemplate; const { fontSizeValue } = defaultTextStyle; const rotation = (-alignment - currentRotation) * _radian; const [layoutStartX, layoutStartY] = currentStampPosition; const [layoutWidth, layoutHeight] = currentDimensions; const coord = requestCoordinate(); const mycell = requestCell(), engine = mycell.engine; // Prepare canvas for work mycell.rotateDestination(engine, layoutStartX, layoutStartY, layoutTemplate); engine.rotate(rotation); const rawLines = requestArray(); let isInLayout, check, sx, sy, ex, ey; const step = _round(fontSizeValue * lineSpacing * currentScale); const rrpX = _round(layoutStartX), xLeft = _round(rrpX - (layoutWidth * currentScale * 2)), xRight = _round(rrpX + (layoutWidth * currentScale * 2)), rrpY = _round(layoutStartY + (lineAdjustment * currentScale)), yTop = _round(rrpY - (layoutHeight * currentScale * 2)), yBase = _round(rrpY + (layoutHeight * currentScale * 2)); if (step) { for (let i = rrpY; i > yTop; i -= step) { const rawLineData = requestArray(); isInLayout = false; check = false; for (let j = xLeft; j < xRight; j++) { check = engine.isPointInPath(pathObject, j, i, winding); if (check !== isInLayout) { rawLineData.push([check === false ? j - 1 : j, i]); isInLayout = check; } } rawLines.push([i, [...rawLineData]]); releaseArray(rawLineData); } for (let i = rrpY + step; i < yBase; i += step) { const rawLineData = requestArray(); isInLayout = false; check = false; for (let j = xLeft; j < xRight; j++) { check = engine.isPointInPath(pathObject, j, i, winding); if (check !== isInLayout) { rawLineData.push([check === false ? j - 1 : j, i]); isInLayout = check; } } rawLines.push([i, [...rawLineData]]); releaseArray(rawLineData); } } // Protecting against a zero value step else { const rawLineData = requestArray(); isInLayout = false; check = false; for (let j = xLeft; j < xRight; j++) { check = engine.isPointInPath(pathObject, j, rrpY, winding); if (check !== isInLayout) { rawLineData.push([check === false ? j - 1 : j, rrpY]); isInLayout = check; } } rawLines.push([rrpY, [...rawLineData]]); releaseArray(rawLineData); } const relevantLines = requestArray(); relevantLines.push(...rawLines.filter(l => l[1].length)); releaseArray(rawLines); relevantLines.sort((a, b) => { if (a[0] > b[0]) return 1; if (a[0] < b[0]) return -1; return 0; }); const selectedLines = relevantLines.map(l => l[1]); if (TEXT_LAYOUT_FLOW_REVERSE.includes(textUnitFlow)) selectedLines.reverse(); releaseArray(relevantLines); selectedLines.forEach(data => { data.forEach(d => { coord.set(d).subtract(currentStampPosition).rotate(alignment + currentRotation).add(currentStampPosition); d[0] = coord[0]; d[1] = coord[1]; }); }); releaseLine(...lines); lines.length = 0; selectedLines.forEach(data => { for (let i = 0, iz = data.length; i < iz; i += 2) { lines.push(requestLine().set({ startAt: data[i], endAt: data[i + 1], })); } }); let path = ''; lines.forEach(line => { [sx, sy] = line.startAt; [ex, ey] = line.endAt; line.length = _hypot(sx - ex, sy - ey); path += `M ${sx}, ${sy} ${ex}, ${ey} `; }); this.guidelinesPath = new Path2D(path); releaseCell(mycell); releaseCoordinate(coord); }; P.preprocessTextForLineBreaks = function (src) { const { autoHyphenate, language, lineBreakHook, lineBreakInsert, cjkPunctuationBinding } = this; if (!src || !src.substring) return src; // Always start with the source so later passes (CJK binding) can run let out = src; if (autoHyphenate) { const insertChar = (lineBreakInsert === SOFT) ? '\u00AD' : '\u200B'; // 1) Developer hook if (isa_fn(lineBreakHook)) { const hookRes = lineBreakHook(src, language); if (Array.isArray(hookRes)) out = hookRes.join(insertChar); else if (hookRes && hookRes.substring) out = hookRes; } // 2) Built-in Intl.Segmenter fallback else { let lang = language || AUTO; if (lang === AUTO) { if (THAI_REGEX.test(src)) lang = 'th'; else if (LAO_REGEX.test(src)) lang = 'lo'; else if (KHMER_REGEX.test(src)) lang = 'km'; else if (MYANMAR_REGEX.test(src))lang = 'my'; else lang = null; } const hasSeg = lang && typeof Intl !== 'undefined' && typeof Intl.Segmenter === 'function' && LANG_TAG_REGEX.test(lang); if (hasSeg) { const seg = new Intl.Segmenter(lang, { granularity: WORD }), parts = seg.segment(src); let assembled = '', prevWord = false; for (const part of parts) { const segmentText = (part && part.segment && part.segment.substring) ? part.segment : ZERO_STR; if (!segmentText) continue; const wordlike = (part && ISWORDLIKE in part) ? !!part.isWordLike : IS_WORDLIKE(segmentText); if (assembled && prevWord && wordlike) assembled += insertChar; assembled += segmentText; prevWord = wordlike; } if (assembled) out = assembled; } } } // 3) CJK punctuation binding can (and should) run even when autoHyphenate is false const wantCjkBind = cjkPunctuationBinding === 'force' || (cjkPunctuationBinding !== 'off' && LOOKS_CJK_RE.test(out)); if (wantCjkBind) out = autoBindCjkPunctuation(out); return out; }; // `cleanText` - Break the entity's text into smaller TextUnit objects which can be positioned within, or along, the layout entity's shape P.cleanText = function () { if (this.currentFontIsLoaded) { this.dirtyText = false; const { breakTextOnSpaces, breakWordsOnHyphens, defaultTextStyle, text, textUnitFlow, textUnits, } = this; // Language-aware preprocessing (opt-in) const processed = this.preprocessTextForLineBreaks(text); const textCharacters = [...processed]; const languageDirectionIsLtr = (defaultTextStyle.direction === LTR); const layoutFlowIsColumns = TEXT_LAYOUT_FLOW_COLUMNS.includes(textUnitFlow); // Decide whether to use vertical CJK forms for this run const useVerticalCjk = layoutFlowIsColumns && (this.verticalCjkPunctuation === FORCE || (this.verticalCjkPunctuation === AUTO && TEXT_LOOKS_CJK_REGEX.test(text))); const unit = []; let noBreak = false; releaseUnit(...textUnits); textUnits.length = 0; let index = 0; if (breakTextOnSpaces) { // + Soft hyphens and truncation marking is deliberately suppressed for RTL fonts if (languageDirectionIsLtr && breakWordsOnHyphens) { textCharacters.forEach(c => { if (TEXT_SPACES_REGEX.test(c)) { textUnits.push(requestUnit({ [UNIT_CHARS]: unit.join(ZERO_STR), [UNIT_TYPE]: TEXT_TYPE_CHARS, index, })); index++; textUnits.push(requestUnit({ [UNIT_CHARS]: c, [UNIT_TYPE]: TEXT_TYPE_SPACE, index, })); unit.length = 0; index++; } else if (TEXT_HARD_HYPHEN_REGEX.test(c)) { textUnits.push(requestUnit({ [UNIT_CHARS]: unit.join(ZERO_STR), [UNIT_TYPE]: TEXT_TYPE_CHARS, index, })); index++; textUnits.push(requestUnit({ [UNIT_CHARS]: c, [UNIT_TYPE]: TEXT_TYPE_HYPHEN, index, })); unit.length = 0; index++; } else if (TEXT_SOFT_HYPHEN_REGEX.test(c)) { textUnits.push(requestUnit({ [UNIT_CHARS]: unit.join(ZERO_STR), [UNIT_TYPE]: TEXT_TYPE_CHARS, index, })); index++; textUnits.push(requestUnit({ [UNIT_CHARS]: c, [UNIT_TYPE]: TEXT_TYPE_SOFT_HYPHEN, index, })); unit.length = 0; index++; } else unit.push(c); }); // Capturing the last word if (unit.length) textUnits.push(requestUnit({ [UNIT_CHARS]: useVerticalCjk ? toVerticalCjkForms(unit.join(ZERO_STR)) : unit.join(ZERO_STR), [UNIT_TYPE]: TEXT_TYPE_CHARS, index, })); } else { textCharacters.forEach(c => { if (TEXT_SPACES_REGEX.test(c)) { textUnits.push(requestUnit({ [UNIT_CHARS]: unit.join(ZERO_STR), [UNIT_TYPE]: TEXT_TYPE_CHARS, index, })); index++; textUnits.push(requestUnit({ [UNIT_CHARS]: c, [UNIT_TYPE]: TEXT_TYPE_SPACE })); unit.length = 0; index++; } else unit.push(c); }); // Capturing the last word if (unit.length) textUnits.push(requestUnit({ [UNIT_CHARS]: useVerticalCjk ? toVerticalCjkForms(unit.join(ZERO_STR)) : unit.join(ZERO_STR), [UNIT_TYPE]: TEXT_TYPE_CHARS, index, })); } } else { textCharacters.forEach((c, i) => { unit.push(useVerticalCjk ? (VERTICAL_PUNCT_MAP.get(c) || c) : c); // Some Chinese/Japanese characters simply have to stick together (but not in columns)! if (!layoutFlowIsColumns) { noBreak = TEXT_NO_BREAK_REGEX.test(c) || TEXT_NO_BREAK_REGEX.test(textCharacters[i + 1]); if (!noBreak) { if (TEXT_SPACES_REGEX.test(c)) { textUnits.push(requestUnit({ [UNIT_CHARS]: unit.join(ZERO_STR), [UNIT_TYPE]: TEXT_TYPE_SPACE, index, })); unit.length = 0; index++; } else { textUnits.push(requestUnit({ [UNIT_CHARS]: unit.join(ZERO_STR), [UNIT_TYPE]: TEXT_TYPE_CHARS, index, })); unit.length = 0; index++; } } } else { if (TEXT_SPACES_REGEX.test(c)) { textUnits.push(requestUnit({ [UNIT_CHARS]: unit.join(ZERO_STR), [UNIT_TYPE]: TEXT_TYPE_SPACE, index, })); unit.length = 0; index++; } else if (TEXT_NO_BREAK_REGEX.test(c)) { textUnits.push(requestUnit({ [UNIT_CHARS]: unit.join(ZERO_STR), [UNIT_TYPE]: TEXT_TYPE_NO_BREAK, index, })); unit.length = 0; index++; } else if (TEXT_ZERO_SPACE_REGEX.test(c)) { textUnits.push(requestUnit({ [UNIT_CHARS]: unit.join(ZERO_STR), [UNIT_TYPE]: TEXT_TYPE_ZERO_SPACE, index, })); unit.length = 0; index++; } else { textUnits.push(requestUnit({ [UNIT_CHARS]: unit.join(ZERO_STR), [UNIT_TYPE]: TEXT_TYPE_CHARS, index, })); unit.length = 0; index++; } } }); } this.assessTextForStyle(); this.measureTextUnits(); this.dirtyTextLayout = true; } }; // `assessTextForStyle` - Add styling details to each TextUnit // + Note that styling on a per-TextUnit basis requires CSS code; there is no way to directly style a TextUnit in SC except by manually replacing its `style` attribute object in code (which is dangerous and definitely not guaranteed to work!) P.assessTextForStyle = function () { const tester = this.getTester(); // No calculator! Reset dirty flag and return if (!tester) { this.dirtyText = true; return null; } // Local helper function `processNode` // + recursively step through the text's HTML nodes const processNode = (node) => { if (node.nodeType !== 3) { for (const item of node.childNodes) { processNode(item); } } else { const unit = textUnits[getCharacterUnit(cursor)]; if (unit != null && unit.style == null) unit.style = makeTextStyle({}); cursor += node.textContent.length; diffStyles(node, unit); } }; // Local helper function `getCharacterUnit` // + called by `processNode`, maps char position to textUnit item const getCharacterUnit = (pos) => { let len = 0; for (let i = 0, iz = textUnits.length; i < iz; i++) { len += textUnits[i].chars.length; if (pos < len) return i; } return null; }; const localState = { localHandleX: '', localHandleY: '', localOffsetX: 0, localOffsetY: 0, localAlignment: 0, }; // Local helper function `diffStyles` // + called by `processNode`, diffs required styles against existing ones const diffStyles = (node, unit) => { const nodeVals = _computed(node.parentNode); const unitSet = {}; let oldVal, newVal, raw; oldVal = currentTextStyle.direction; newVal = nodeVals.getPropertyValue('direction'); if (oldVal !== newVal) unitSet.direction = newVal; oldVal = currentTextStyle.fontFamily; newVal = nodeVals.getPropertyValue('font-family'); if (oldVal !== newVal) unitSet.fontFamily = newVal; oldVal = currentTextStyle.fontKerning; newVal = nodeVals.getPropertyValue('font-kerning'); if (oldVal !== newVal) unitSet.fontKerning = newVal; oldVal = currentTextStyle.fontSize; newVal = nodeVals.getPropertyValue('font-size'); if (oldVal !== newVal) { unitSet.fontSize = newVal; if (FONT_VIEWPORT_LENGTH_REGEX.test(newVal)) this.usingViewportFontSizing = true; } oldVal = currentTextStyle.fontStretch; newVal = nodeVals.getPropertyValue('font-stretch'); if (newVal === '100%') newVal = NORMAL; if (oldVal !== newVal) unitSet.fontStretch = newVal; oldVal = currentTextStyle.fontStyle; newVal = nodeVals.getPropertyValue('font-style'); if (oldVal !== newVal) unitSet.fontStyle = newVal; oldVal = currentTextStyle.fontVariantCaps; newVal = nodeVals.getPropertyValue('font-variant-caps'); if (oldVal !== newVal) unitSet.fontVariantCaps = newVal; oldVal = currentTextStyle.fontWeight; newVal = nodeVals.getPropertyValue('font-weight'); if (oldVal !== newVal) unitSet.fontWeight = newVal; oldVal = currentTextStyle.get('letterSpacing'); newVal = nodeVals.getPropertyValue('letter-spacing'); if (newVal === NORMAL) newVal = PX0; if (oldVal !== newVal) unitSet.letterSpacing = newVal; oldVal = currentTextStyle.textRendering; newVal = nodeVals.getPropertyValue('text-rendering'); if (oldVal !== newVal) unitSet.textRendering = newVal; oldVal = currentTextStyle.get('wordSpacing'); newVal = nodeVals.getPropertyValue('word-spacing'); if (oldVal !== newVal) unitSet.wordSpacing = newVal; oldVal = currentTextStyle.fillStyle; newVal = nodeVals.getPropertyValue('--SC-fill-style'); if (oldVal !== newVal) unitSet.fillStyle = newVal; oldVal = currentTextStyle.includeHighlight; raw = nodeVals.getPropertyValue('--SC-include-highlight').trim().toLowerCase(); newVal = (raw === 'true' || raw === '1'); if (oldVal !== newVal) unitSet.includeHighlight = newVal; oldVal = currentTextStyle.highlightStyle; newVal = nodeVals.getPropertyValue('--SC-highlight-style'); if (oldVal !== newVal) unitSet.highlightStyle = newVal; oldVal = currentTextStyle.lineWidth; raw = parseFloat(nodeVals.getPropertyValue('--SC-stroke-width')); if (_isFinite(raw) && oldVal !== raw) unitSet.lineWidth = raw; oldVal = currentTextStyle.includeOverline; raw = nodeVals.getPropertyValue('--SC-include-overline').trim().toLowerCase(); newVal = (raw === 'true' || raw === '1'); if (oldVal !== newVal) unitSet.includeOverline = newVal; oldVal = currentTextStyle.overlineOffset; raw = parseFloat(nodeVals.getPropertyValue('--SC-overline-offset')); if (_isFinite(raw) && oldVal !== raw) unitSet.overlineOffset = raw; oldVal = currentTextStyle.overlineStyle; newVal = nodeVals.getPropertyValue('--SC-overline-style'); if (oldVal !== newVal) unitSet.overlineStyle = newVal; oldVal = currentTextStyle.overlineWidth; raw = parseFloat(nodeVals.getPropertyValue('--SC-overline-width')); if (_isFinite(raw) && oldVal !== raw) unitSet.overlineWidth = raw; oldVal = currentTextStyle.includeUnderline; raw = nodeVals.getPropertyValue('--SC-include-underline').trim().toLowerCase(); newVal = (raw === 'true' || raw === '1'); if (oldVal !== newVal) unitSet.includeUnderline = newVal; oldVal = currentTextStyle.underlineGap; raw = parseFloat(nodeVals.getPropertyValue('--SC-underline-gap')); if (_isFinite(raw) && oldVal !== raw) unitSet.underlineGap = raw; oldVal = currentTextStyle.underlineOffset; raw = parseFloat(nodeVals.getPropertyValue('--SC-underline-offset')); if (_isFinite(raw) && oldVal !== raw) unitSet.underlineOffset = raw; oldVal = currentTextStyle.underlineStyle; newVal = nodeVals.getPropertyValue('--SC-underline-style'); if (oldVal !== newVal) unitSet.underlineStyle = newVal; oldVal = currentTextStyle.underlineWidth; raw = parseFloat(nodeVals.getPropertyValue('--SC-underline-width')); if (_isFinite(raw) && oldVal !== raw) unitSet.underlineWidth = raw; oldVal = currentTextStyle.method; newVal = nodeVals.getPropertyValue('--SC-method'); if (oldVal !== newVal) unitSet.method = newVal; oldVal = localState.localHandleX; newVal = nodeVals.getPropertyValue('--SC-local-handle-x'); if (oldVal !== newVal) unitSet.localHandleX = newVal; oldVal = localState.localHandleY; newVal = nodeVals.getPropertyValue('--SC-local-handle-y'); if (oldVal !== newVal) unitSet.localHandleY = newVal; oldVal = localState.localOffsetX; raw = parseFloat(nodeVals.getPropertyValue('--SC-local-offset-x')); if (_isFinite(raw) && oldVal !== raw) unitSet.localOffsetX = raw; oldVal = localState.localOffsetY; raw = parseFloat(nodeVals.getPropertyValue('--SC-local-offset-y')); if (_isFinite(raw) && oldVal !== raw) unitSet.localOffsetY = raw; oldVal = localState.localAlignment; raw = parseFloat(nodeVals.getPropertyValue('--SC-local-alignment')); if (_isFinite(raw) && oldVal !== raw) unitSet.localAlignment = raw; unit.set(unitSet); unit.style.set(unitSet, true); currentTextStyle.set(unitSet, true); }; // Local helper function `setupTester` // + called by main function, assigns default text styles to the tester div const setupTester = () => { tester.style.setProperty('direction', defaultTextStyle.direction); tester.style.setProperty('font-family', defaultTextStyle.fontFamily); tester.style.setProperty('font-kerning', defaultTextStyle.fontKerning); tester.style.setProperty('font-size', defaultTextStyle.fontSize); tester.style.setProperty('font-stretch', defaultTextStyle.fontStretch); tester.style.setProperty('font-style', defaultTextStyle.fontStyle); tester.style.setProperty('font-variant-caps', defaultTextStyle.fontVariantCaps); tester.style.setProperty('font-weight', defaultTextStyle.fontWeight); tester.style.setProperty('letter-spacing', defaultTextStyle.get('letterSpacing')); tester.style.setProperty('text-rendering', defaultTextStyle.textRendering); tester.style.setProperty('word-spacing', defaultTextStyle.get('wordSpacing')); tester.style.setProperty('--SC-fill-style', defaultTextStyle.fillStyle); tester.style.setProperty('--SC-highlight-style', defaultTextStyle.highlightStyle); tester.style.setProperty('--SC-overline-offset', defaultTextStyle.overlineOffset); tester.style.setProperty('--SC-overline-style', defaultTextStyle.overlineStyle); tester.style.setProperty('--SC-overline-width', defaultTextStyle.overlineWidth); tester.style.setProperty('--SC-stroke-width', defaultTextStyle.lineWidth); tester.style.setProperty('--SC-stroke-style', defaultTextStyle.strokeStyle); tester.style.setProperty('--SC-underline-gap', defaultTextStyle.underlineGap); tester.style.setProperty('--SC-underline-offset', defaultTextStyle.underlineOffset); tester.style.setProperty('--SC-underline-style', defaultTextStyle.underlineStyle); tester.style.setProperty('--SC-underline-width', defaultTextStyle.underlineWidth); tester.