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
JavaScript
// # 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 (­ 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 <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.