rapier-markdown-kit 1.2.7 → 1.2.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +32 -30
- package/dist/agent/will.mjs +1 -6
- package/dist/images/gif.mjs +0 -3
- package/dist/images/playback.mjs +1 -10
- package/dist/kit/assets.mjs +0 -1
- package/dist/kit/conformance/measurer.mjs +0 -0
- package/dist/kit/conformance/run.mjs +1 -8
- package/dist/kit/export-fonts.mjs +0 -14
- package/dist/kit/index.mjs +1 -1
- package/dist/kit/ledger/authored-origin.mjs +0 -5
- package/dist/kit/ledger/authorship.mjs +0 -2
- package/dist/kit/ledger/carried.d.mts +20 -0
- package/dist/kit/ledger/carried.mjs +4 -7
- package/dist/kit/ledger/data.mjs +0 -8
- package/dist/kit/ledger/format.mjs +0 -25
- package/dist/kit/ledger/hash.mjs +11 -7
- package/dist/kit/ledger/history.mjs +10 -29
- package/dist/kit/ledger/inverse-source.mjs +0 -46
- package/dist/kit/ledger/journal-records.mjs +24 -56
- package/dist/kit/ledger/merge.mjs +0 -27
- package/dist/kit/ledger/text-merge.mjs +0 -20
- package/dist/kit/ledger/transport.mjs +0 -47
- package/dist/kit/ledger.d.mts +88 -0
- package/dist/kit/ledger.mjs +14 -0
- package/dist/kit/markdown-it.d.mts +4 -0
- package/dist/kit/markdown-it.mjs +36 -0
- package/dist/kit/pdf-glyphs.mjs +39 -45
- package/dist/kit/pdf-source.d.mts +5 -0
- package/dist/kit/pdf-source.mjs +125 -0
- package/dist/kit/pdf-vectors.mjs +0 -3
- package/dist/kit/print-capitals.mjs +0 -4
- package/dist/kit/render-entry.mjs +0 -1
- package/dist/kit/render-ink.mjs +0 -5
- package/dist/kit/render-markdown.mjs +15 -119
- package/dist/kit/render-print.mjs +4 -49
- package/dist/kit/render-sanitize.mjs +22 -58
- package/dist/kit/render-styles.mjs +0 -7
- package/dist/kit/render-work.mjs +0 -12
- package/dist/kit/render.mjs +18 -173
- package/dist/kit/will.d.mts +42 -0
- package/dist/layout/hyphenation.mjs +0 -23
- package/dist/layout/line-plan.mjs +0 -20
- package/dist/layout/model.mjs +0 -55
- package/dist/layout/paragraph-fit.mjs +50 -60
- package/dist/layout/preparation.mjs +23 -56
- package/dist/layout/prepared-paragraph.mjs +0 -25
- package/dist/layout/typography.mjs +103 -41
- package/dist/packages/rapier-diff/concerns.mjs +0 -16
- package/dist/packages/rapier-diff/diff.mjs +0 -7
- package/dist/packages/rapier-diff/index.mjs +0 -1
- package/dist/packages/rapier-diff/matching.mjs +0 -19
- package/dist/packages/rapier-diff/merge.mjs +0 -9
- package/dist/packages/rapier-diff/moves.mjs +0 -7
- package/dist/packages/rapier-diff/structure.mjs +0 -2
- package/dist/packages/rapier-diff/tokens.mjs +0 -6
- package/dist/packages/rapier-diff/walk.mjs +0 -12
- package/dist/spec/document-fonts.mjs +0 -9
- package/dist/spec/document-settings.mjs +6 -25
- package/dist/spec/frontmatter.mjs +0 -1
- package/dist/spec/html-blocks.mjs +3 -37
- package/dist/spec/md-assets.mjs +2 -61
- package/dist/spec/md-layout.mjs +0 -34
- package/dist/spec/md-marks.mjs +0 -51
- package/dist/spec/md-source.mjs +0 -16
- package/dist/spec/word-sidecar.mjs +0 -2
- package/package.json +21 -4
- package/style.css +38 -21
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
// Will/1: permissions a Markdown document gives agents, as HTML comments on their own lines.
|
|
3
|
+
// <!-- will/1 keep --> ... <!-- /will --> keep: no change; append: add after the region's text only; edit: any change
|
|
4
|
+
export type WillLaw = 'keep' | 'append' | 'edit';
|
|
5
|
+
export type WillFault = 'malformed_marker' | 'unpaired_marker';
|
|
6
|
+
/** A paired region; `start` to `end` is its text between the marker lines (UTF-16 offsets). */
|
|
7
|
+
export interface WillRegion {
|
|
8
|
+
index: number; law: WillLaw; intent: string | null;
|
|
9
|
+
openerLine: number; openerStart: number; openerEnd: number; openerContentEnd: number;
|
|
10
|
+
closerLine: number; closerStart: number; closerEnd: number;
|
|
11
|
+
start: number; end: number;
|
|
12
|
+
}
|
|
13
|
+
export interface WillMarker { kind: 'open' | 'close' | 'near'; law: WillLaw | ''; intent: string | null; content: string; fault?: WillFault; line?: number; start?: number; end?: number; contentEnd?: number; }
|
|
14
|
+
export interface Will {
|
|
15
|
+
/** True when the text has any marker line. */
|
|
16
|
+
present: boolean;
|
|
17
|
+
/** The first fault, or ''. A document with a fault is all keep. */
|
|
18
|
+
fault: WillFault | '';
|
|
19
|
+
faults: {mode: WillFault; line: number; start: number; end: number}[];
|
|
20
|
+
/** Every marker line, as [start, end) with its terminator. */
|
|
21
|
+
blocks: {start: number; end: number}[];
|
|
22
|
+
markers: WillMarker[];
|
|
23
|
+
regions: WillRegion[];
|
|
24
|
+
text: string;
|
|
25
|
+
}
|
|
26
|
+
/** A quick test for any marker line. */
|
|
27
|
+
export function hasWillMarkers(text: string): boolean;
|
|
28
|
+
export function parseWill(text: string): Will;
|
|
29
|
+
/** The law over [start, end): 'keep' when a fault, a marker or a keep region is touched; 'append' inside an append region; else 'edit'. */
|
|
30
|
+
export function willGovern(will: Will, start: number, end: number): WillLaw;
|
|
31
|
+
/** The regions [start, end) touches (an empty range: the regions it stands in). */
|
|
32
|
+
export function willRegionsIn(will: Will, start: number, end: number): WillRegion[];
|
|
33
|
+
/** The region or marker line [start, end) touches, or null. */
|
|
34
|
+
export function willTouchesMarker(will: Will, start: number, end: number): WillRegion | {law: 'keep'} | null;
|
|
35
|
+
/** The intent words of the one region wholly containing [start, end), or ''. */
|
|
36
|
+
export function willIntentOf(will: Will, start: number, end: number): string;
|
|
37
|
+
/** Reads one line as a marker, or null. */
|
|
38
|
+
export function willMarkerOf(line: string): WillMarker | null;
|
|
39
|
+
/** The width of a line terminator ending at `last` (char code), with `prior` before it: 0, 1 or 2. */
|
|
40
|
+
export function terminatorWidth(last: number, prior: number): number;
|
|
41
|
+
/** The text without one trailing line terminator. */
|
|
42
|
+
export function stripOneTerminator(text: string): string;
|
|
@@ -1,9 +1,6 @@
|
|
|
1
1
|
// SPDX-License-Identifier: MIT
|
|
2
2
|
import {next, weight, exceptions, maximum} from '../agent/vendor/hyphen-en-us/patterns.js';
|
|
3
3
|
|
|
4
|
-
// An original matcher over the permitted Kuiken/Plain TeX data. Its maximum pattern
|
|
5
|
-
// length bounds the work per input character. Its static prefix data need no decoder,
|
|
6
|
-
// runtime trie or font. Prepared paragraphs own the resulting positions for their lifetime.
|
|
7
4
|
function englishCuts(word) {
|
|
8
5
|
if (!/^[A-Z]?[a-z]+$/.test(word)) return null;
|
|
9
6
|
if (word.length < 6) return [];
|
|
@@ -25,13 +22,6 @@ function englishCuts(word) {
|
|
|
25
22
|
return cuts;
|
|
26
23
|
}
|
|
27
24
|
|
|
28
|
-
// Ask the host's offline hyphenator for successive word cuts. A zero-width word exposes
|
|
29
|
-
// its first available cut; increasing the minimum prefix then exposes the next one.
|
|
30
|
-
// Merely reading every line at width zero misses useful cuts (e.g. justifi-ca-tion).
|
|
31
|
-
// Each round writes every live word first, then reads every word: no per-word layout
|
|
32
|
-
// loop. This supplies positions only, never reusable font metrics or a second shaper.
|
|
33
|
-
// Three graphemes on either side and at most one automatic split per word are deliberate
|
|
34
|
-
// typography policy. Hosts without a dictionary for the language naturally return no cuts.
|
|
35
25
|
export function paragraphHyphens(words, text, items, offsets, language, document) {
|
|
36
26
|
const result = new Map();
|
|
37
27
|
if (!language) return result;
|
|
@@ -40,14 +30,10 @@ export function paragraphHyphens(words, text, items, offsets, language, document
|
|
|
40
30
|
for (const word of words) {
|
|
41
31
|
if (word.protected) continue;
|
|
42
32
|
const body = text.slice(word.start, word.end);
|
|
43
|
-
// The exact ASCII subset avoids Unicode-regexp setup for ordinary English.
|
|
44
33
|
const match = /^[-!"#%&'()*,.\/:;?@\[\]\\_{}]*([A-Za-z]+)[-!"#%&'()*,.\/:;?@\[\]\\_{}]*$/.exec(body) ||
|
|
45
34
|
/^\p{P}*([\p{L}\p{M}]+)\p{P}*$/u.exec(body);
|
|
46
35
|
if (!match || /[\u00a0\u00ad\u202f\u2060]/u.test(body)) continue;
|
|
47
36
|
const start = word.start + body.indexOf(match[1]), end = start + match[1].length;
|
|
48
|
-
// The prepared source words and runs are ordered. Visit only the overlapping
|
|
49
|
-
// runs, retaining the exact open-boundary policy even for empty authored runs.
|
|
50
|
-
// A hard-break chunk can begin far into the shared paragraph run array.
|
|
51
37
|
if (itemIndex < 0) {
|
|
52
38
|
let lo = 0, hi = items.length;
|
|
53
39
|
while (lo < hi) {
|
|
@@ -69,8 +55,6 @@ export function paragraphHyphens(words, text, items, offsets, language, document
|
|
|
69
55
|
let entry = unique.get(match[1]);
|
|
70
56
|
if (!entry) {
|
|
71
57
|
if (match[1].length < 6) continue;
|
|
72
|
-
// Exact English needs neither ICU grapheme setup nor native DOM probing.
|
|
73
|
-
// Language admission belongs to preparation; this is only its fallback owner.
|
|
74
58
|
segmenter ||= new Intl.Segmenter(language, {granularity:'grapheme'});
|
|
75
59
|
const units = [...segmenter.segment(match[1])].map(part => part.index);
|
|
76
60
|
if (units.length < 6) continue;
|
|
@@ -81,8 +65,6 @@ export function paragraphHyphens(words, text, items, offsets, language, document
|
|
|
81
65
|
jobs.push({word,start,entry});
|
|
82
66
|
}
|
|
83
67
|
if (!jobs.length) return result;
|
|
84
|
-
// If this host cannot expose its language opportunities, preserve native automatic
|
|
85
|
-
// hyphenation for the paragraph instead of projecting a weaker whole-word substitute.
|
|
86
68
|
if (!document?.body || !document.defaultView?.CSS.supports('hyphenate-limit-chars', '6 3 3')) return null;
|
|
87
69
|
const host = document.createElement('span');
|
|
88
70
|
host.setAttribute('aria-hidden','true'); host.inert = true;
|
|
@@ -91,8 +73,6 @@ export function paragraphHyphens(words, text, items, offsets, language, document
|
|
|
91
73
|
let active = [...unique.values()];
|
|
92
74
|
for (const entry of active) {
|
|
93
75
|
const element = document.createElement('div'); element.lang = language;
|
|
94
|
-
// Do not reset `all` here: Blink's language declaration is a UA style, and an
|
|
95
|
-
// inline reset would erase it. The shadow root already isolates author selectors.
|
|
96
76
|
element.style.cssText = 'display:block;width:0;font:16px serif;line-height:20px;hyphens:auto;hyphenate-limit-chars:6 3 3;white-space:normal;word-break:normal;overflow-wrap:normal;text-wrap:wrap;';
|
|
97
77
|
element.textContent = entry.text; shadow.append(element); entry.element = element;
|
|
98
78
|
}
|
|
@@ -105,8 +85,6 @@ export function paragraphHyphens(words, text, items, offsets, language, document
|
|
|
105
85
|
const node = entry.element.firstChild, {units} = entry;
|
|
106
86
|
const top = index => {
|
|
107
87
|
range.setStart(node,units[index]); range.setEnd(node,units[index+1]);
|
|
108
|
-
// At a native discretionary boundary a character Range can also report
|
|
109
|
-
// the preceding line's hyphen rectangle. Its final rect owns the character.
|
|
110
88
|
return [...range.getClientRects()].at(-1)?.top;
|
|
111
89
|
};
|
|
112
90
|
const first = top(0), last = top(units.length-2);
|
|
@@ -117,7 +95,6 @@ export function paragraphHyphens(words, text, items, offsets, language, document
|
|
|
117
95
|
entry.cuts.push(units[lo]); entry.minimum = lo+1;
|
|
118
96
|
if (entry.minimum <= units.length-4) next.push(entry);
|
|
119
97
|
}
|
|
120
|
-
// All reads above finish before any mutation for the next shared layout.
|
|
121
98
|
for (const entry of next) entry.element.style.hyphenateLimitChars = '6 '+entry.minimum+' 3';
|
|
122
99
|
active = next;
|
|
123
100
|
}
|
|
@@ -4,8 +4,6 @@ const lastSource = line => line.fragments.at(-1);
|
|
|
4
4
|
const afterSource = (point, previous) => !!point && (!previous || point.itemIndex > previous.itemIndex ||
|
|
5
5
|
point.itemIndex === previous.itemIndex && point.sourceEnd > previous.sourceEnd);
|
|
6
6
|
|
|
7
|
-
// A chip may continue on later rows but does not cross a picture within the same row.
|
|
8
|
-
// Containment uses the original item and materialized source interval, never engine cursors.
|
|
9
7
|
function insideChip(items, line) {
|
|
10
8
|
const last = lastSource(line), item = items?.[last?.itemIndex];
|
|
11
9
|
return item?.extraWidth > 0 && typeof item.text === 'string' &&
|
|
@@ -27,12 +25,6 @@ function beforeChip(flow, range, line, cursor, items, next, materialize) {
|
|
|
27
25
|
return null;
|
|
28
26
|
}
|
|
29
27
|
|
|
30
|
-
// A lone alphabetic prefix in a narrow picture-side slot need not be split when the
|
|
31
|
-
// same engine continuation fits its whole word in a later slot on this row. This is
|
|
32
|
-
// a small placement policy, not a reconstruction of upstream legal break candidates.
|
|
33
|
-
// Authored no-break spaces stay with this alphabetic group. Punctuation, discretionary
|
|
34
|
-
// hyphens, mixed items and non-alphabetic scripts retain
|
|
35
|
-
// the engine's choice. All ranges and cursors remain untouched public API values.
|
|
36
28
|
function laterWholeWord(flow, cursor, line, items, bands, slot, next, materialize) {
|
|
37
29
|
if (line.fragments.length !== 1) return false;
|
|
38
30
|
const first = line.fragments[0], item = items?.[first.itemIndex], text = item?.text;
|
|
@@ -63,8 +55,6 @@ function laterWholeWord(flow, cursor, line, items, bands, slot, next, materializ
|
|
|
63
55
|
return false;
|
|
64
56
|
}
|
|
65
57
|
|
|
66
|
-
// Upstream owns every emitted break and emergency advance. Ordinary CSS balance
|
|
67
|
-
// stays native; Draw's existing token-only balance is kept independently below.
|
|
68
58
|
export function linePlan(flow, width, top, obstacles, lineHeight, minWidth, direction, balance,
|
|
69
59
|
slotsForBand, layoutNextRichInlineLineRange, materializeRichInlineLineRange, items = null) {
|
|
70
60
|
if (!flow || balance) return null;
|
|
@@ -104,8 +94,6 @@ export function linePlan(flow, width, top, obstacles, lineHeight, minWidth, dire
|
|
|
104
94
|
return null;
|
|
105
95
|
}
|
|
106
96
|
|
|
107
|
-
// Draw's numeric tokens predate the rich engine contract and are not vendor handles. This is
|
|
108
|
-
// its existing whole-column greedy walk, with its existing balancing decisions below.
|
|
109
97
|
function measuredPlan(flow, width, lineHeight, fontSize, next, materialize) {
|
|
110
98
|
const lines = [], ranges = [];
|
|
111
99
|
let cursor = {itemIndex: 0, segmentIndex: 0, graphemeIndex: 0};
|
|
@@ -119,11 +107,6 @@ function measuredPlan(flow, width, lineHeight, fontSize, next, materialize) {
|
|
|
119
107
|
return null;
|
|
120
108
|
}
|
|
121
109
|
|
|
122
|
-
// Draw's existing `text-wrap: balance` approximation, over its own measured token breaks:
|
|
123
|
-
// `balance` is the block's font size. Only a run of two to six greedy lines, each on the whole column, no word broken, balances.
|
|
124
|
-
// The score breaker minimises (width - line)² per line with Minikin's penalties (4·width·font a line, 2·width·font a soft hyphen)
|
|
125
|
-
// and 10000 on the break before the last word; it stands when there are three breaks to choose from and it keeps greedy's count.
|
|
126
|
-
// Otherwise the narrowest width that keeps the count, bisected to a pixel up from 0.8 of the mean line, laid greedy.
|
|
127
110
|
function balanced(flow, width, fontSize, lines, greedy, lineHeight, layoutNextRichInlineLineRange, materializeRichInlineLineRange) {
|
|
128
111
|
const count = greedy.length;
|
|
129
112
|
if (count < 2 || count > 6 || lines.some((line, index) => line.x !== 0 || line.width !== width || line.y !== index * lineHeight) ||
|
|
@@ -137,7 +120,6 @@ function balanced(flow, width, fontSize, lines, greedy, lineHeight, layoutNextRi
|
|
|
137
120
|
const last = range.fragments.at(-1), item = flow.items[last.itemIndex];
|
|
138
121
|
return last.end.graphemeIndex === 0 && item?.prepared.kinds[last.end.segmentIndex - 1] === 'soft-hyphen';
|
|
139
122
|
};
|
|
140
|
-
// Every break a line from `from` can end at inside the column, farthest first: each narrower try yields the break before.
|
|
141
123
|
const reach = from => {
|
|
142
124
|
const found = [];
|
|
143
125
|
for (let limit = width, previous = Infinity; found.length < 4096;) {
|
|
@@ -195,8 +177,6 @@ function balanced(flow, width, fontSize, lines, greedy, lineHeight, layoutNextRi
|
|
|
195
177
|
return solved?.length === count ? place(solved) : lines;
|
|
196
178
|
}
|
|
197
179
|
|
|
198
|
-
// Draw supplies its SVG advances. Every door uses the same line plan without a canvas;
|
|
199
|
-
// the tokens retain the source offsets and keep words and Unicode clusters intact.
|
|
200
180
|
export function measuredLines(tokens, width, lineHeight, fontSize) {
|
|
201
181
|
if (!tokens.length) return [];
|
|
202
182
|
const prepared = {kinds: tokens.map(token => token.space ? 'space' : 'text')}, flow = {items: [{prepared}]};
|
package/dist/layout/model.mjs
CHANGED
|
@@ -8,10 +8,6 @@ export {captureFontAuthority, createPreparationScope, invalidateRichMetrics, pre
|
|
|
8
8
|
|
|
9
9
|
const finite = value => typeof value === 'number' && Number.isFinite(value);
|
|
10
10
|
const space = code => code === 32 || code === 9 || code === 10 || code === 13 || code === 12;
|
|
11
|
-
// Upstream materialization supplies ORIGINAL item UTF-16 offsets. The only local work is
|
|
12
|
-
// mapping the observed displayed text through whitespace/soft-hyphen spelling in that exact
|
|
13
|
-
// interval. Cursors and engine grapheme/segment tables remain opaque. A normalization the
|
|
14
|
-
// mapper cannot reproduce retains the native paragraph, never an approximate source address.
|
|
15
11
|
export function mapFragment(run, fragment, {whiteSpace = 'normal'} = {}) {
|
|
16
12
|
const raw = typeof run === 'string' ? run : run?.raw;
|
|
17
13
|
if (run?.break === 'never') whiteSpace = 'normal';
|
|
@@ -38,16 +34,12 @@ export function mapFragment(run, fragment, {whiteSpace = 'normal'} = {}) {
|
|
|
38
34
|
}
|
|
39
35
|
let text = pieces.join('');
|
|
40
36
|
if (lastSoftHyphen === end - 1 && fragment.text === text + '-') {
|
|
41
|
-
// Only the engine's actual materialized discretionary cut supplies this glyph.
|
|
42
37
|
offsets[offsets.length - 1] = lastSoftHyphen;
|
|
43
38
|
offsets.push(end); text += '-';
|
|
44
39
|
}
|
|
45
40
|
return text === fragment.text ? {text, offsets, start, end} : null;
|
|
46
41
|
}
|
|
47
42
|
|
|
48
|
-
// The line a picture is measured in (`lines`, spec/md-layout.mjs): the anchor's line box, its type's cap height, and where that
|
|
49
|
-
// cap height stands in the first line box (the half-leading model: the baseline sits half the leading below the box's top plus
|
|
50
|
-
// the ascent). Read from the font as the page lays it out, so a text size or a line spacing moves the picture with the words.
|
|
51
43
|
export function createLineMeasurer(authority = null) {
|
|
52
44
|
const measurers = new WeakMap();
|
|
53
45
|
const finite = value => typeof value === 'number' && Number.isFinite(value);
|
|
@@ -57,8 +49,6 @@ export function createLineMeasurer(authority = null) {
|
|
|
57
49
|
const computed = view.getComputedStyle(element), size = parseFloat(computed.fontSize) || 16;
|
|
58
50
|
const font = computed.fontStyle + ' ' + computed.fontWeight + ' ' + size + 'px ' + computed.fontFamily;
|
|
59
51
|
const document = element.ownerDocument, epoch = authority?.captureFontAuthority(document);
|
|
60
|
-
// The factory is also serialized on its own by the offline typography script. Without
|
|
61
|
-
// an injected authority it stores only the canvas, and measures afresh in its loaded font.
|
|
62
52
|
if (authority) { if (!authority.fontReady(document, font, 'H')) return null; }
|
|
63
53
|
else {
|
|
64
54
|
try {
|
|
@@ -79,7 +69,6 @@ export function createLineMeasurer(authority = null) {
|
|
|
79
69
|
const capital = context.measureText('H');
|
|
80
70
|
metrics = {ascent: capital.fontBoundingBoxAscent, descent: capital.fontBoundingBoxDescent, cap: capital.actualBoundingBoxAscent};
|
|
81
71
|
if (![metrics.ascent, metrics.descent, metrics.cap].every(finite) || !(metrics.cap > 0)) return null;
|
|
82
|
-
// Only bounded metrics of this document's currently loaded faces are retained.
|
|
83
72
|
if (state.metrics) {
|
|
84
73
|
if (state.metrics.size === 64) state.metrics.delete(state.metrics.keys().next().value);
|
|
85
74
|
state.metrics.set(font, metrics);
|
|
@@ -92,7 +81,6 @@ export function createLineMeasurer(authority = null) {
|
|
|
92
81
|
|
|
93
82
|
export const lineMetrics = createLineMeasurer({captureFontAuthority, fontReady});
|
|
94
83
|
|
|
95
|
-
// `lines` tall: from the first line's cap height to the Nth line's baseline, N - 1 lines and one cap.
|
|
96
84
|
export function linesHeight(lines, metrics) {
|
|
97
85
|
const finite = value => typeof value === 'number' && Number.isFinite(value);
|
|
98
86
|
const height = Number.isInteger(lines) && lines > 0 && metrics && finite(metrics.line) && finite(metrics.cap)
|
|
@@ -100,15 +88,12 @@ export function linesHeight(lines, metrics) {
|
|
|
100
88
|
return height > 0 ? height : null;
|
|
101
89
|
}
|
|
102
90
|
|
|
103
|
-
// A picture sized in lines keeps its standoff off the line after its last: below its foot (the Nth baseline) the gap stops
|
|
104
|
-
// at that line's bottom, so exactly N lines stand beside it.
|
|
105
91
|
export function linesSlices(slices, layout, metrics, y, height) {
|
|
106
92
|
if (layout?.lines == null || !metrics || !(slices?.length)) return slices;
|
|
107
93
|
const floor = y + height + Math.max(0, metrics.line - metrics.capTop - metrics.cap) - .5;
|
|
108
94
|
return slices.filter(slice => slice.y < floor).map(slice => slice.y + slice.height > floor ? {...slice, height: floor - slice.y} : slice);
|
|
109
95
|
}
|
|
110
96
|
|
|
111
|
-
// Where a picture's top stands in its anchor before `y`: a picture sized in lines stands on the first line's cap height.
|
|
112
97
|
export const linesTop = (layout, metrics) => layout?.lines != null && finite(metrics?.capTop) ? metrics.capTop : 0;
|
|
113
98
|
|
|
114
99
|
export function imageBox(columnWidth, naturalWidth, naturalHeight, layout = {}, defaultWidth = naturalWidth, metrics = null) {
|
|
@@ -123,7 +108,6 @@ export function imageBox(columnWidth, naturalWidth, naturalHeight, layout = {},
|
|
|
123
108
|
return finite(height) && height > 0 ? {x, width, height} : null;
|
|
124
109
|
}
|
|
125
110
|
|
|
126
|
-
// Fit-then-clamp: scale until the turned AABB fits the column, then clamp its x. One result for live editor, styled export and Share.
|
|
127
111
|
export function rasterReserved(columnWidth, unrotated, rad) {
|
|
128
112
|
if (!unrotated || ![columnWidth, unrotated.x, unrotated.width, unrotated.height].every(value => finite(value) && value >= 0) ||
|
|
129
113
|
columnWidth <= 0 || unrotated.width <= 0 || unrotated.height <= 0) return null;
|
|
@@ -143,14 +127,8 @@ export function rasterReserved(columnWidth, unrotated, rad) {
|
|
|
143
127
|
|
|
144
128
|
|
|
145
129
|
const shapeGrid = 48, alphaThreshold = .1, alphaOversample = 4, profileBands = new WeakMap();
|
|
146
|
-
// Slices are cut at up to this many bands: a picture shown 6 px a band or finer keeps every row the profile holds.
|
|
147
130
|
const sliceGridMax = shapeGrid * alphaOversample, slicePixelsPerBand = 6;
|
|
148
131
|
|
|
149
|
-
// The picture's silhouette as 192 bands of horizontal runs, read from its alpha at that resolution both ways: a band holds the
|
|
150
|
-
// ink of one sampled row, and a run's ends fall on a 192nd of the picture's width, so the standoff a slice adds (`pictureSlices`'
|
|
151
|
-
// `gap`) is the least distance from the words to the paint however large the picture is drawn. At 48 rows and columns a run's
|
|
152
|
-
// end drifted by a 48th of the width (9 px at 430), a thin stroke fell between two samples, and a tall picture's bands were
|
|
153
|
-
// 20 px high. The descriptor an exported page carries (`serializeProfile`) still writes 48 bands.
|
|
154
132
|
export function alphaProfile(image) {
|
|
155
133
|
const src = image.currentSrc || image.getAttribute('src') || '';
|
|
156
134
|
if (!image.complete || !image.naturalWidth || !image.naturalHeight || !src || /^data:image\/jpe?g[;,]/i.test(src)) return null;
|
|
@@ -185,7 +163,6 @@ export function alphaProfile(image) {
|
|
|
185
163
|
spanAt(t) { const runs = runsAt(t); return runs.length ? [runs[0][0], runs.at(-1)[1]] : null; }};
|
|
186
164
|
}
|
|
187
165
|
|
|
188
|
-
// Scanline intersection over a normalized polygon; the one implementation for live and export wrap=box.
|
|
189
166
|
export function polygonProfile(corners) {
|
|
190
167
|
return {spanAt(t) {
|
|
191
168
|
const xs = [];
|
|
@@ -200,15 +177,12 @@ export function polygonProfile(corners) {
|
|
|
200
177
|
}};
|
|
201
178
|
}
|
|
202
179
|
|
|
203
|
-
// w'=w|cos|+h|sin|, h'=w|sin|+h|cos| (markdown-standard.md). Shared by browser.js, interchange.js and share.js.
|
|
204
180
|
export function rotatedBoundsRad(width, height, rad) {
|
|
205
181
|
if (!rad) return {width, height};
|
|
206
182
|
return {width: width * Math.abs(Math.cos(rad)) + height * Math.abs(Math.sin(rad)),
|
|
207
183
|
height: width * Math.abs(Math.sin(rad)) + height * Math.abs(Math.cos(rad))};
|
|
208
184
|
}
|
|
209
185
|
|
|
210
|
-
// The `wrap=box` obstacle for a turned raster: its own untilted rectangle's four corners, rotated
|
|
211
|
-
// `rad` about its centre, normalized into the turned bounding box's own [0,1] space.
|
|
212
186
|
export function rasterTiltProfile(width, height, rad) {
|
|
213
187
|
const cs = Math.cos(rad), sn = Math.sin(rad), rotated = rotatedBoundsRad(width, height, rad);
|
|
214
188
|
const local = [[-width / 2, -height / 2], [width / 2, -height / 2], [width / 2, height / 2], [-width / 2, height / 2]];
|
|
@@ -216,7 +190,6 @@ export function rasterTiltProfile(width, height, rad) {
|
|
|
216
190
|
(rotated.height / 2 + lx * sn + ly * cs) / rotated.height]));
|
|
217
191
|
}
|
|
218
192
|
|
|
219
|
-
// Each 48x48 cell of the turned box is unrotated into the raster's own pixels and sampled.
|
|
220
193
|
export function rotatedRasterAlpha(baseAlpha, width, height, rad) {
|
|
221
194
|
if (!baseAlpha) return null;
|
|
222
195
|
if (!rad) return baseAlpha;
|
|
@@ -241,7 +214,6 @@ export function rotatedRasterAlpha(baseAlpha, width, height, rad) {
|
|
|
241
214
|
return bandsProfile(bands);
|
|
242
215
|
}
|
|
243
216
|
|
|
244
|
-
// One occupancy descriptor: the editor's profile sampled at 48 band centres, shipped as data-rapier-occupancy, read back here.
|
|
245
217
|
export function bandsProfile(bands) {
|
|
246
218
|
if (!Array.isArray(bands) || !bands.length) return null;
|
|
247
219
|
const at = t => bands[Math.max(0, Math.min(bands.length - 1, Math.floor(t * bands.length)))] || [];
|
|
@@ -258,7 +230,6 @@ export function serializeProfile(profile, count = shapeGrid) {
|
|
|
258
230
|
const clean = (runs || []).map(([a, b]) => [Math.max(0, Math.min(1, +a)), Math.max(0, Math.min(1, +b))]).filter(([a, b]) => Number.isFinite(a) && Number.isFinite(b) && b > a);
|
|
259
231
|
bands.push(clean.map(([a, b]) => {
|
|
260
232
|
let left = Math.round(a * 1e4), right = Math.round(b * 1e4);
|
|
261
|
-
// A narrow occupied run must not round to an invalid zero-width descriptor.
|
|
262
233
|
if (left === right) { left = Math.floor(a * 1e4); right = Math.ceil(b * 1e4); }
|
|
263
234
|
return (left / 1e4) + '-' + (right / 1e4);
|
|
264
235
|
}).join(','));
|
|
@@ -273,14 +244,8 @@ export function parseProfile(text) {
|
|
|
273
244
|
return bandsProfile(bands);
|
|
274
245
|
}
|
|
275
246
|
|
|
276
|
-
// The standoff is round: a band's own rows are pushed out by the whole gap, and the rows above and below it, out to a gap away,
|
|
277
|
-
// by what a circle of that radius allows at their distance, in three steps. A square standoff stood up to 14 px off a slanted
|
|
278
|
-
// stroke and 10 off a level one; this stands 10 off both, within a step.
|
|
279
247
|
const shoulderSteps = 3, shoulders = Array.from({length: shoulderSteps}, (_, k) => ({from: k / shoulderSteps, to: (k + 1) / shoulderSteps, reach: Math.sqrt(1 - ((k + .5) / shoulderSteps) ** 2)}));
|
|
280
248
|
|
|
281
|
-
// The reference scale at an element (docs/markdown-standard.md, "The reference scale"): its unit, `--md-unit` (a registered
|
|
282
|
-
// length, 16 px at M), over 16. Every length the planner adds of its own (the standoff, a band's height, the room between two
|
|
283
|
-
// pictures) is that many reference pixels times it, so a zoom moves no word against a picture.
|
|
284
249
|
export function frameScale(element) {
|
|
285
250
|
const view = element?.ownerDocument?.defaultView;
|
|
286
251
|
const unit = view ? parseFloat(view.getComputedStyle(element).getPropertyValue('--md-unit')) : NaN;
|
|
@@ -290,7 +255,6 @@ export function frameScale(element) {
|
|
|
290
255
|
export function pictureSlices(profile, x, y, width, height, scale = 1) {
|
|
291
256
|
const gap = 10 * scale;
|
|
292
257
|
if (!profile) return [{x: x - gap, y: y - gap, width: width + gap * 2, height: height + gap * 2}];
|
|
293
|
-
// A band is at most 6 reference px high at the shown size, never coarser than the descriptor's 48 nor finer than the profile's 192.
|
|
294
258
|
const count = Math.max(shapeGrid, Math.min(sliceGridMax, Math.ceil((finite(height) && height > 0 ? height : 0) / (slicePixelsPerBand * scale))));
|
|
295
259
|
let byCount = profileBands.get(profile);
|
|
296
260
|
if (!byCount) profileBands.set(profile, byCount = new Map());
|
|
@@ -300,14 +264,12 @@ export function pictureSlices(profile, x, y, width, height, scale = 1) {
|
|
|
300
264
|
let previous = new Map();
|
|
301
265
|
for (let index = 0; index < count; index++) {
|
|
302
266
|
const top = index / count, bottom = (index + 1) / count;
|
|
303
|
-
// Sampled just inside the bottom edge: on the edge itself a band profile answers with the next band's row and the slice runs a row long.
|
|
304
267
|
const runs = [top, (top + bottom) / 2, bottom - 1e-9].flatMap(t => {
|
|
305
268
|
if (profile.runsAt) return profile.runsAt(t) || [];
|
|
306
269
|
const span = profile.spanAt(t);
|
|
307
270
|
return span ? [span] : [];
|
|
308
271
|
}).filter(run => Number.isFinite(run[0]) && Number.isFinite(run[1]) && run[1] > run[0])
|
|
309
272
|
.sort((a, b) => a[0] - b[0]);
|
|
310
|
-
// Preserve transparent holes and gaps; only overlapping runs share an obstacle.
|
|
311
273
|
const merged = [];
|
|
312
274
|
for (const run of runs) {
|
|
313
275
|
const tail = merged.at(-1);
|
|
@@ -361,10 +323,6 @@ export function slotsForBand(width, obstacles, top, height, minWidth = 40) {
|
|
|
361
323
|
return slots;
|
|
362
324
|
}
|
|
363
325
|
|
|
364
|
-
// A picture moves a block's words only where its outline (with its standoff) meets them. The block is laid as it lies with no
|
|
365
|
-
// picture beside it, balanced and aligned in the whole column; when no line of words there meets an obstacle, that is its
|
|
366
|
-
// layout. Otherwise its lines flow around the outline. A short heading can fit beside the outline in a slot narrower
|
|
367
|
-
// than minWidth, so start-aligned, unbalanced words need the same contact check.
|
|
368
326
|
export function flowLines(flow, width, top, obstacles, lineHeight, minWidth, direction = 'ltr', balance = 0, align = 'start') {
|
|
369
327
|
const items = paragraphItems(flow);
|
|
370
328
|
const plan = linePlan(flow, width, top, obstacles, lineHeight, minWidth, direction, balance, slotsForBand, layoutNextRichInlineLineRange, materializeRichInlineLineRange, items);
|
|
@@ -383,8 +341,6 @@ export function flowLines(flow, width, top, obstacles, lineHeight, minWidth, dir
|
|
|
383
341
|
return meets ? plan : free;
|
|
384
342
|
}
|
|
385
343
|
|
|
386
|
-
// ---- The one wrap shape (live: globalThis.RapierImageLayout; export: modules["layout/model.mjs"]) ----
|
|
387
|
-
// `obstacles` in the paragraph's coordinates. Null when nothing to flow around or too narrow for a float.
|
|
388
344
|
export function wrapShape(side, obstacles, width, top) {
|
|
389
345
|
const clamp = (value, low, high) => Math.min(high, Math.max(low, value));
|
|
390
346
|
const rows = obstacles.filter(obstacle => (obstacle.x > width - (obstacle.x + obstacle.width) ? 'right' : 'left') === side);
|
|
@@ -417,10 +373,6 @@ export function wrapShape(side, obstacles, width, top) {
|
|
|
417
373
|
return {boxWidth, boxHeight, startY, points};
|
|
418
374
|
}
|
|
419
375
|
|
|
420
|
-
// ---- What a block draws at its edge moves with its words (live and export alike) ----
|
|
421
|
-
// A bordered box (a disclosure) cannot follow an outline line by line: it stands clear of it. `obstacles` in the box's
|
|
422
|
-
// border-box coordinates, its band [top, top + height]: the insets that end the box where the outline begins on each
|
|
423
|
-
// side, `fits` false when they leave less than `floor`; null when the outline does not meet the box.
|
|
424
376
|
export function boxClearance(obstacles, width, top, height, floor) {
|
|
425
377
|
let left = 0, right = 0;
|
|
426
378
|
for (const row of obstacles) {
|
|
@@ -431,14 +383,12 @@ export function boxClearance(obstacles, width, top, height, floor) {
|
|
|
431
383
|
return left || right ? {left, right, fits: width - left - right >= floor} : null;
|
|
432
384
|
}
|
|
433
385
|
|
|
434
|
-
// Where an element's content begins at the inline start (a right edge counts negative in a right-to-left block).
|
|
435
386
|
function contentStart(element) {
|
|
436
387
|
const style = element.ownerDocument.defaultView.getComputedStyle(element), box = element.getBoundingClientRect();
|
|
437
388
|
return style.direction === 'rtl' ? -(box.right - (parseFloat(style.paddingRight) || 0) - (parseFloat(style.borderRightWidth) || 0))
|
|
438
389
|
: box.left + (parseFloat(style.paddingLeft) || 0) + (parseFloat(style.borderLeftWidth) || 0);
|
|
439
390
|
}
|
|
440
391
|
|
|
441
|
-
// The lines of words in `root` (text outside `skip`), each with its block, where it starts and where its block's content starts.
|
|
442
392
|
export function edgeLines(root, skip = () => false) {
|
|
443
393
|
const doc = root.ownerDocument, view = doc.defaultView, lines = [], starts = new Map();
|
|
444
394
|
const walker = doc.createTreeWalker(root, 4);
|
|
@@ -460,9 +410,6 @@ export function edgeLines(root, skip = () => false) {
|
|
|
460
410
|
return lines;
|
|
461
411
|
}
|
|
462
412
|
|
|
463
|
-
// After a picture's floats settle: how far each line moved from where its block's lines began without them (`before`,
|
|
464
|
-
// edgeLines read first). A list item's marker moves as far as its first line; a quote's bar moves to stand beside its
|
|
465
|
-
// moved lines as it stood beside them before, in runs of one shift from the quote's top to its bottom.
|
|
466
413
|
export function edgeMarks(root, before, skip = () => false) {
|
|
467
414
|
const view = root.ownerDocument.defaultView, home = new Map();
|
|
468
415
|
const byBlock = rows => {
|
|
@@ -488,7 +435,6 @@ export function edgeMarks(root, before, skip = () => false) {
|
|
|
488
435
|
const shift = shiftOf(item);
|
|
489
436
|
if (shift > .5) markers.push({item, shift});
|
|
490
437
|
}
|
|
491
|
-
// A numbered item's dotted line runs to the next circle: where the two circles moved apart it is not drawn (`steps`).
|
|
492
438
|
const steps = [];
|
|
493
439
|
for (const list of within('ol')) {
|
|
494
440
|
const items = [...list.children].filter(child => child.tagName === 'LI');
|
|
@@ -517,7 +463,6 @@ export function edgeMarks(root, before, skip = () => false) {
|
|
|
517
463
|
return {markers, bars, steps};
|
|
518
464
|
}
|
|
519
465
|
|
|
520
|
-
// ---- The one "paragraph holds only the picture": one meaningful child, the image or an <a> wrapping only it ----
|
|
521
466
|
export function imageOnly(paragraph, image) {
|
|
522
467
|
const meaningful = node => [...node.childNodes].filter(child => child.nodeType !== 8 &&
|
|
523
468
|
!(child.nodeType === 3 && !child.textContent.trim()));
|
|
@@ -1,10 +1,4 @@
|
|
|
1
1
|
// SPDX-License-Identifier: MIT
|
|
2
|
-
// Original paragraph-wide optimization over immutable Pretext geometry. Four spacing
|
|
3
|
-
// classes and the discretionary run length are the actual transition policy, not
|
|
4
|
-
// buckets approximating a continuous history. The numerical solver requests exact
|
|
5
|
-
// missing edges; the preparation scope observes them before declaring a plan current.
|
|
6
|
-
// Normal spaces retain 60–180% of their natural advance. At most two consecutive
|
|
7
|
-
// discretionary endings are allowed, and the final line starts whole.
|
|
8
2
|
export function createParagraphFitter() {
|
|
9
3
|
const indexes = new WeakMap();
|
|
10
4
|
function prepare(input) {
|
|
@@ -15,7 +9,6 @@ export function createParagraphFitter() {
|
|
|
15
9
|
segment.words.forEach((word, index) => {
|
|
16
10
|
prefix.push(prefix.at(-1) + word.width + (word.gap?.width || 0));
|
|
17
11
|
gaps.push(gaps.at(-1) + (word.gap?.width || 0));
|
|
18
|
-
// Conservative bound for interior whole words; no substring-width subtraction.
|
|
19
12
|
minimum.push(minimum.at(-1) + Math.max(0, word.width - word.endTrim) + Math.max(0, (word.gap?.width || 0) * .6));
|
|
20
13
|
word.breaks.forEach((cut, cutIndex) => points.push({word: index, cut, cutIndex, end: cut.offset, next: cut.offset, kind: cut.kind}));
|
|
21
14
|
points.push({word: index + 1, cut: null, cutIndex: -1, end: word.end, next: word.gap?.end ?? word.end,
|
|
@@ -24,22 +17,23 @@ export function createParagraphFitter() {
|
|
|
24
17
|
if (segment.words.length) points[0].end = points[0].next = segment.words[0].start;
|
|
25
18
|
return {...segment, prefix, gaps, minimum, points};
|
|
26
19
|
});
|
|
27
|
-
// Numeric indexes are private and never written after this point. Their opaque
|
|
28
|
-
// handle keeps hot numeric arrays out of public custody without freezing them
|
|
29
|
-
// into a different element representation on each engine.
|
|
30
20
|
const handle = Object.freeze({});
|
|
31
21
|
indexes.set(handle, {text: input.text, segments});
|
|
32
22
|
return handle;
|
|
33
23
|
}
|
|
34
24
|
|
|
35
|
-
function* solveSteps(handle, width, {indent = 0, left = 0, right = 0} = {}, known) {
|
|
25
|
+
function* solveSteps(handle, width, {indent = 0, left = 0, right = 0, widths = null} = {}, known) {
|
|
36
26
|
const prepared = indexes.get(handle);
|
|
37
27
|
if (!prepared || ![width, indent, left, right].every(Number.isFinite) || width <= 0) return null;
|
|
28
|
+
const measures = Array.isArray(widths) && widths.every(value => Number.isFinite(value) && value > 0) ? widths : [];
|
|
29
|
+
const measure = line => line < measures.length ? measures[line] : width;
|
|
30
|
+
const widest = Math.max(width, ...measures);
|
|
38
31
|
const lines = []; let work = 0, visited = 0, totalCost = 0;
|
|
39
32
|
for (let segmentIndex = 0; segmentIndex < prepared.segments.length; segmentIndex++) {
|
|
40
33
|
const {words, points, prefix, gaps, minimum} = prepared.segments[segmentIndex];
|
|
41
34
|
if (!words.length) continue;
|
|
42
|
-
const
|
|
35
|
+
const base = lines.length, classes = Math.max(1, measures.length - base + 1);
|
|
36
|
+
const ceiling = widest + Math.max(0, left) + Math.max(0, right) + Math.max(0, -indent);
|
|
43
37
|
const lastIndex = points.length - 1;
|
|
44
38
|
let smallest = Infinity;
|
|
45
39
|
for (let index = 0; index < words.length - 1; index++) smallest = Math.min(smallest, words[index].gap?.width || 0);
|
|
@@ -48,14 +42,11 @@ export function createParagraphFitter() {
|
|
|
48
42
|
natural += words[0].startExtra;
|
|
49
43
|
const opening = Math.min(Math.max(0, left), words[0].opening);
|
|
50
44
|
const closing = Math.min(Math.max(0, right), words.at(-1).closing);
|
|
51
|
-
const target =
|
|
45
|
+
const target = measure(base) - (segmentIndex === 0 ? indent : 0) + opening + closing;
|
|
52
46
|
const count = words.length - 1;
|
|
53
47
|
const delta = !count ? 0 : Math.max(-smallest * .4, Math.min(0, (target - natural) / count));
|
|
54
48
|
const overflow = natural + delta * count - target;
|
|
55
49
|
const cost = 1 + 100 * Math.max(0, overflow / target) ** 2;
|
|
56
|
-
// Every edge costs at least one and every transition penalty is nonnegative.
|
|
57
|
-
// This is the original start-to-final arithmetic, so a valid single edge below
|
|
58
|
-
// two is the exact global optimum. Keep legal cuts for the overflow exception.
|
|
59
50
|
if (Number.isFinite(natural) && target > 0 && cost < 2 && minimum[words.length - 1] - minimum[Math.min(words.length - 1, 1)] <= ceiling + .02 &&
|
|
60
51
|
!(overflow > .02 && (count || words.at(-1).breaks.length))) {
|
|
61
52
|
lines.push({start: points[0].next, end: points[lastIndex].end, next: points[lastIndex].next, kind: 'end', glyph: '',
|
|
@@ -63,16 +54,11 @@ export function createParagraphFitter() {
|
|
|
63
54
|
totalCost += cost; work++; continue;
|
|
64
55
|
}
|
|
65
56
|
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
// replaced state. Build source line records only for the final chosen path.
|
|
69
|
-
const costs = new Float64Array(points.length * 12);
|
|
57
|
+
const stride = classes * 12;
|
|
58
|
+
const costs = new Float64Array(points.length * stride);
|
|
70
59
|
const previous = new Float64Array(costs.length), spacing = new Float64Array(costs.length);
|
|
71
|
-
const prefixes = [], suffixes = [], reachable = new Uint16Array(points.length);
|
|
60
|
+
const prefixes = [], suffixes = [], reachable = new Uint16Array(points.length * classes);
|
|
72
61
|
reachable[0] = 1 << 3;
|
|
73
|
-
// The final line rejects a discretionary start. In its last word, automatic
|
|
74
|
-
// points cannot lead to any later cut either. Authored soft points need a later
|
|
75
|
-
// ordinary cut to reset that state. Retain breaks.length for overflow policy.
|
|
76
62
|
let lastPlain = lastIndex - 1;
|
|
77
63
|
while (lastPlain > 0 && (points[lastPlain].kind === 'soft' || points[lastPlain].kind === 'auto')) lastPlain--;
|
|
78
64
|
for (let endIndex = 1; endIndex < points.length; endIndex++) {
|
|
@@ -88,9 +74,9 @@ export function createParagraphFitter() {
|
|
|
88
74
|
while (minWord > start.word) smallest = Math.min(smallest, words[--minWord].gap?.width || 0);
|
|
89
75
|
if (final && (start.kind === 'soft' || start.kind === 'auto')) continue;
|
|
90
76
|
if (start.cut && end.cut && start.word === end.word && (start.kind === 'auto' || end.kind === 'auto')) continue;
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
if (!
|
|
77
|
+
let any = 0;
|
|
78
|
+
for (let lineClass = 0; lineClass < classes; lineClass++) any |= reachable[startIndex * classes + lineClass];
|
|
79
|
+
if (!(any & (discretionary ? 0x6db : 0xfff))) continue;
|
|
94
80
|
let natural;
|
|
95
81
|
if (start.cut && end.cut && start.word === end.word) {
|
|
96
82
|
natural = yield {segment: segmentIndex, word: start.word, from: start.cutIndex, to: end.cutIndex};
|
|
@@ -117,49 +103,53 @@ export function createParagraphFitter() {
|
|
|
117
103
|
const count = lastWord - start.word, gapWidth = gaps[lastWord] - gaps[start.word];
|
|
118
104
|
const opening = start.cut ? 0 : Math.min(Math.max(0, left), words[start.word].opening);
|
|
119
105
|
const closing = end.cut ? 0 : Math.min(Math.max(0, right), words[lastWord].closing);
|
|
120
|
-
const target = width - (segmentIndex === 0 && startIndex === 0 ? indent : 0) + opening + closing;
|
|
121
|
-
if (target <= 0) continue;
|
|
122
|
-
const remaining = target - natural;
|
|
123
|
-
const delta = !count ? 0 : Math.max(-smallest * .4, Math.min(final ? 0 : smallest * .8, remaining / count));
|
|
124
|
-
const overflow = natural + delta * count - target;
|
|
125
|
-
// A truly unbreakable word/atom can stand alone at its natural width. Never
|
|
126
|
-
// fabricate an emergency source cut or eject the surrounding paragraph.
|
|
127
|
-
if (overflow > .02 && (count || start.cut || end.cut || words[lastWord].breaks.length)) continue;
|
|
128
|
-
const residual = Math.max(0, remaining - delta * count);
|
|
129
|
-
const ratio = gapWidth > 0 ? delta * count / gapWidth : 0;
|
|
130
|
-
const fitness = final ? 1 : ratio < -.075 ? 0 : ratio <= .2 ? 1 : ratio <= .5 ? 2 : 3;
|
|
131
|
-
let local = 1 + 100 * Math.max(0, overflow / target) ** 2;
|
|
132
|
-
if (final && startIndex) local += 100 * Math.max(0, .45 - natural / target) ** 2 + (count === 0 ? 25 : 0);
|
|
133
|
-
else if (!final) local += (ratio < 0 ? 100 : 12.5) * ratio ** 2 + 2400 * (residual / target) ** 2;
|
|
134
106
|
const priorDiscretionary = start.kind === 'soft' || start.kind === 'auto';
|
|
135
|
-
if (discretionary) local += 12 + (priorDiscretionary ? 12 : 0);
|
|
136
107
|
if (final && priorDiscretionary) continue;
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
const
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
const
|
|
145
|
-
|
|
146
|
-
const
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
108
|
+
for (let lineClass = 0; lineClass < classes; lineClass++) {
|
|
109
|
+
const available = reachable[startIndex * classes + lineClass] & (discretionary ? 0x6db : 0xfff);
|
|
110
|
+
if (!available) continue;
|
|
111
|
+
const target = measure(base + lineClass) - (segmentIndex === 0 && startIndex === 0 ? indent : 0) + opening + closing;
|
|
112
|
+
if (target <= 0) continue;
|
|
113
|
+
const remaining = target - natural;
|
|
114
|
+
const delta = !count ? 0 : Math.max(-smallest * .4, Math.min(final ? 0 : smallest * .8, remaining / count));
|
|
115
|
+
const overflow = natural + delta * count - target;
|
|
116
|
+
if (overflow > .02 && (count || start.cut || end.cut || words[lastWord].breaks.length)) continue;
|
|
117
|
+
const residual = Math.max(0, remaining - delta * count);
|
|
118
|
+
const ratio = gapWidth > 0 ? delta * count / gapWidth : 0;
|
|
119
|
+
const fitness = final ? 1 : ratio < -.075 ? 0 : ratio <= .2 ? 1 : ratio <= .5 ? 2 : 3;
|
|
120
|
+
let local = 1 + 100 * Math.max(0, overflow / target) ** 2;
|
|
121
|
+
if (final && startIndex) local += 100 * Math.max(0, .45 - natural / target) ** 2 + (count === 0 ? 25 : 0);
|
|
122
|
+
else if (!final) local += (ratio < 0 ? 100 : 12.5) * ratio ** 2 + 2400 * (residual / target) ** 2;
|
|
123
|
+
if (discretionary) local += 12 + (priorDiscretionary ? 12 : 0);
|
|
124
|
+
const set = !final && count && residual > 0 ? remaining / count : delta;
|
|
125
|
+
const nextClass = Math.min(lineClass + 1, classes - 1);
|
|
126
|
+
for (let active = available; active; active &= active - 1) {
|
|
127
|
+
const slot = 31 - Math.clz32(active & -active), priorIndex = startIndex * stride + lineClass * 12 + slot;
|
|
128
|
+
const hyphens = discretionary ? slot % 3 + 1 : 0;
|
|
129
|
+
if (hyphens > 2) continue;
|
|
130
|
+
work++;
|
|
131
|
+
const cost = costs[priorIndex] + local + (!final && startIndex ? .6 * (fitness - Math.floor(slot / 3)) ** 2 : 0);
|
|
132
|
+
const stateIndex = fitness * 3 + hyphens;
|
|
133
|
+
const index = endIndex * stride + nextClass * 12 + stateIndex;
|
|
134
|
+
const bit = endIndex * classes + nextClass;
|
|
135
|
+
if (reachable[bit] & 1 << stateIndex && costs[index] <= cost) continue;
|
|
136
|
+
costs[index] = cost; previous[index] = priorIndex; spacing[index] = set;
|
|
137
|
+
reachable[bit] |= 1 << stateIndex;
|
|
138
|
+
}
|
|
150
139
|
}
|
|
151
140
|
}
|
|
152
141
|
}
|
|
153
142
|
let best = -1;
|
|
154
|
-
for (let
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
143
|
+
for (let lineClass = 0; lineClass < classes; lineClass++) for (let slot = 0; slot < 12; slot++)
|
|
144
|
+
if (reachable[lastIndex * classes + lineClass] & 1 << slot) {
|
|
145
|
+
const index = lastIndex * stride + lineClass * 12 + slot;
|
|
146
|
+
if (best < 0 || costs[index] < costs[best]) best = index;
|
|
147
|
+
}
|
|
158
148
|
if (best < 0) return null;
|
|
159
149
|
totalCost += costs[best];
|
|
160
150
|
const chosen = [];
|
|
161
151
|
for (let endIndex = lastIndex; endIndex;) {
|
|
162
|
-
const priorIndex = previous[best], startIndex = Math.floor(priorIndex /
|
|
152
|
+
const priorIndex = previous[best], startIndex = Math.floor(priorIndex / stride);
|
|
163
153
|
const start = points[startIndex], end = points[endIndex], lastWord = end.cut ? end.word : end.word - 1;
|
|
164
154
|
chosen.push({start: start.next, end: end.end, next: end.next, kind: end.kind, glyph: end.cut?.glyph || '',
|
|
165
155
|
wordSpacing: spacing[best], opening: start.cut ? 0 : Math.min(Math.max(0, left), words[start.word].opening),
|