const Alinea = {
helpers: {
hyphenCost: 75,
runtCost: 50,
maxBadness: 1e9,
endsInHyphen: segment =>
!!(segment.needsHyphen || segment.text.match(/-$/))
},
settings: {
locale: "en-GB"
},
hyphen: {
cache: {},
dict: {},
patternToPoints: pattern => {
let points = [];
pattern.split("").forEach(glyph => {
if (glyph.match(/[0-9]/)) {
points.pop();
points.push(+glyph);
} else {
points.push(0);
}
});
return points;
},
mergeBreakpoints: (wordPoints, patternPoints, endIdx) => {
const startIdx = endIdx - patternPoints.length;
for (idx = 0; idx < patternPoints.length; idx++) {
wordPoints[startIdx + idx] = Math.max(
wordPoints[startIdx + idx],
patternPoints[idx]
);
}
},
hyphenateWord: (word, patterns) => {
if (word.length < 5 || !patterns || word.includes("\u00AD")) {
return word;
}
const baseWord = word.toLocaleLowerCase(Alinea.settings.locale);
if (word in Alinea.hyphen.cache) {
return Alinea.hyphen.cache[word];
}
let breakpoints = new Array(word.length).fill(0);
for (letter = 2; letter <= word.length; letter++) {
const fragment = baseWord.slice(0, letter);
let matches = patterns.general.filter(pattern =>
fragment.match(pattern[0])
);
if (letter == word.length) {
matches.push(
...patterns.final.filter(pattern =>
fragment.match(pattern[0])
)
);
}
for (const matchedPattern of matches) {
Alinea.hyphen.mergeBreakpoints(
breakpoints,
Alinea.hyphen.patternToPoints(matchedPattern[1]),
letter
);
}
}
breakpoints[0] = 0;
breakpoints[breakpoints.length - 1] = 0;
breakpoints[breakpoints.length - 2] = 0;
const breakpointIndices = breakpoints
.map((el, idx) => (el % 2 ? idx + 1 : 0))
.filter(el => el != 0);
let hyphenated = [];
for (idx = 0; idx <= breakpointIndices.length; idx++) {
if (idx == 0) {
hyphenated.push(word.slice(0, breakpointIndices[idx]));
} else if (idx == breakpointIndices.length) {
hyphenated.push(word.slice(breakpointIndices[idx - 1]));
} else {
hyphenated.push(
word.slice(
breakpointIndices[idx - 1],
breakpointIndices[idx]
)
);
}
}
hyphenated = hyphenated.join("\u00AD");
Alinea.hyphen.cache[word] = hyphenated;
return hyphenated;
}
},
measure: {
canvas: new OffscreenCanvas(1, 1),
ctx: null,
widthCache: {},
width: (input, font) => {
Alinea.measure.ctx.font = font;
const measuredWidth = Alinea.measure.ctx.measureText(input).width;
if (!(font in Alinea.measure.widthCache)) {
Alinea.measure.widthCache[font] = {};
}
Alinea.measure.widthCache[font][input] = measuredWidth;
return measuredWidth;
},
getElementFont: el => {
const styles = window.getComputedStyle(el);
const fontSize = styles.getPropertyValue("font-size");
const fontFamily = styles.getPropertyValue("font-family");
const fontStyle = styles.getPropertyValue("font-style");
const fontWeight = styles.getPropertyValue("font-weight");
return `${fontStyle} ${fontWeight} ${fontSize} ${fontFamily}`;
}
},
segmentText: (string, font, node = null) => {
const segmenter = new Intl.Segmenter(Alinea.settings.locale, {
granularity: "word"
});
const segmentedString = [...segmenter.segment(string)]
.map(word => ({
segment: Alinea.hyphen.hyphenateWord(
word.segment,
Alinea.hyphen.dict[Alinea.settings.locale]
),
index: word.index
}))
.flatMap(word =>
word.segment.includes("\u00AD")
? word.segment.split("\u00AD").map((el, idx, arr) => ({
segment: el,
index:
word.index +
arr.slice(0, idx + 1, idx).join("").length,
needsHyphen: idx + 1 < arr.length,
hyphenSplit: [
arr.slice(0, idx + 1).join("").length,
arr.slice(idx + 1).join("").length
]
}))
: {
segment: word.segment,
index: word.index + word.segment.length
}
);
let segments = [];
for (const el of segmentedString) {
const isGlue = !!el.segment.match(/^\s+$/u);
if (segments.length) {
const lastSegment = segments.at(-1);
if (el.segment == "\u00AD") {
lastSegment.needsHyphen = true;
continue;
}
if (
lastSegment.type == "box" &&
!(
isGlue ||
Alinea.helpers.endsInHyphen(lastSegment) ||
lastSegment.text.match(/–—\//) ||
el.segment.match(/–—/)
)
) {
lastSegment.text = lastSegment.text.concat(el.segment);
lastSegment.width += Alinea.measure.width(el.segment, font);
continue;
}
}
const measuredWidth = Alinea.measure.width(el.segment, font);
segments.push({
text: el.segment,
index: el.index,
type: isGlue ? "glue" : "box",
width: Alinea.measure.width(el.segment, font),
widthPlusHyphen: Alinea.measure.width(`${el.segment}-`, font),
stretch: isGlue ? measuredWidth * 0.5 : 0,
shrink: isGlue ? measuredWidth * 0.333 : 0,
needsHyphen: el.needsHyphen ?? false,
hyphenSplit: el.hyphenSplit ?? null,
node: node,
log: {
baseCost: -1
}
});
}
return segments;
},
adjustmentRatio: (line, goalWidth) => {
if (line.length == 0) return 1e9;
if (line.at(-1).type == "glue" && line.at(-1).stretch != Infinity) {
line = line.slice(0, -1);
}
if (line.length == 0) return 1e9;
let lineWidth = line.map(el => el.width).reduce((a, b) => a + b);
const lineStretch = line.map(el => el.stretch).reduce((a, b) => a + b);
const lineShrink = line.map(el => el.shrink).reduce((a, b) => a + b);
if (line.at(-1).needsHyphen) {
lineWidth =
lineWidth - line.at(-1).width + line.at(-1).widthPlusHyphen;
}
if (lineWidth == goalWidth) return 0;
if (lineWidth < goalWidth) return (lineWidth - goalWidth) / lineStretch;
return (lineWidth - goalWidth) / lineShrink;
},
lineCost: (line, goalWidth) => {
if (line[0].type == "glue") {
line = line.slice(1);
}
const ratio = Alinea.adjustmentRatio(line, goalWidth);
const badness =
ratio > 1 ? Alinea.helpers.maxBadness : 100 * Math.abs(ratio) ** 3;
let penalty = 0;
if (
line.filter(el => el.type == "glue").length == 1 &&
line.at(-1).type == "glue"
) {
console.log(line, badness, penalty);
penalty += Alinea.helpers.runtCost;
}
if (line.length && Alinea.helpers.endsInHyphen(line.at(-1))) {
if (line.at(-1).needsHyphen) {
let worstHyphenSplit = Math.min(
line.at(-1).hyphenSplit[0] * 1.5,
line.at(-1).hyphenSplit[1]
);
penalty +=
Alinea.helpers.hyphenCost *
(1 + 15 / worstHyphenSplit ** 3);
} else {
penalty += Alinea.helpers.hyphenCost;
}
}
return (1 + badness + penalty) ** 2;
},
solveKnuthPlass: (segments, startIdx = 0, goalWidth = 70) => {
let currentLine = [segments[startIdx]];
let lineWidth = segments[startIdx].width;
let bestCost = Infinity;
let bestTail = null;
segments.forEach(seg => {
if (!(goalWidth in seg.log)) {
seg.log[goalWidth] = {
bestCost: seg.log.baseCost,
tail: null
};
}
});
for (let idx = startIdx + 1; idx <= segments.length; idx++) {
if (
idx < segments.length &&
segments[idx].log[goalWidth].bestCost == -1
) {
Alinea.solveKnuthPlass(segments, idx, goalWidth);
}
if (lineWidth > goalWidth * 1.5) {
break;
}
let currentCost = Alinea.lineCost(currentLine, goalWidth);
const nextBestCost =
idx < segments.length
? segments[idx].log[goalWidth].bestCost
: 0;
if (currentCost + nextBestCost <= bestCost) {
bestCost = currentCost + nextBestCost;
bestTail = idx;
}
if (idx < segments.length) {
currentLine.push(segments[idx]);
lineWidth += segments[idx].width;
}
}
segments[startIdx].log[goalWidth].bestCost = bestCost;
segments[startIdx].log[goalWidth].tail = bestTail;
return segments;
},
prettyLinebreak: (segments, goalWidths) => {
if (typeof goalWidths === "number") {
goalWidths = [goalWidths];
}
for (const gw of goalWidths) {
Alinea.solveKnuthPlass(segments, 0, gw);
}
let lines = [[segments[0]]];
let lineIdx = 0;
let tail = segments[0].log[goalWidths[lineIdx]].tail;
for (let idx = 1; idx < segments.length; idx++) {
if (idx == tail) {
lines.push([segments[idx]]);
if (lineIdx + 1 < goalWidths.length) {
lineIdx++;
}
tail = segments[idx].log[goalWidths[lineIdx]].tail;
} else {
lines.at(-1).push(segments[idx]);
}
}
return lines;
},
reflowElement: element => {
const elWidth = element.getBoundingClientRect().width;
const elIndent = window
.getComputedStyle(element)
.getPropertyValue("text-indent")
.slice(0, -2);
const flatNodes = list =>
[...list].flatMap(node =>
node.nodeType == Node.ELEMENT_NODE
? flatNodes(node.childNodes)
: node
);
const nicerNodes = flatNodes(element.childNodes).filter(
node => node.nodeType == Node.TEXT_NODE
);
let segments = [];
for (const node of nicerNodes) {
let nodeText = node.data;
if (!node.previousSibling) {
nodeText = nodeText.replace(/^\s+/, "");
}
if (!node.nextSibling) {
nodeText = nodeText.replace(/\s+$/, "");
}
nodeText = nodeText.replace(/\s+/g, " ");
if (nodeText === "") {
if (node.previousSibling || node.nextSibling) {
continue;
}
}
node.data = nodeText;
segments.push(
...Alinea.segmentText(
nodeText,
Alinea.measure.getElementFont(node.parentElement),
node
)
);
}
segments.push({
text: "",
type: "glue",
width: 0,
stretch: Infinity,
shrink: 0,
needsHyphen: false,
node: null,
log: {
baseCost: 0
}
});
const breakpoints = Alinea.prettyLinebreak(segments, [
elWidth - +elIndent,
elWidth
]).map(line => {
line.at(-1).cumulativeWidth = line
.map(x => x.width)
.reduce((a, b) => a + b);
return line.at(-1);
});
console.log(breakpoints);
breakpoints.forEach((breakpoint, idx) => {
let postfix;
if (!breakpoint.node || idx == breakpoints.length - 1) {
return;
}
if (breakpoint.index == 0) {
postfix = breakpoint.node;
} else {
postfix = breakpoint.node.splitText(breakpoint.index);
const nodesNeedingUpdate = breakpoints
.slice(idx + 1)
.filter(el => el.node == breakpoint.node);
nodesNeedingUpdate.forEach(el => {
el.node = postfix;
el.index -= breakpoint.index;
});
if (breakpoint.needsHyphen) {
breakpoint.node.data = `${breakpoint.node.data}-`;
}
}
postfix.parentElement.insertBefore(
document.createElement("br"),
postfix
);
});
element.innerHTML += `<span class="alinea-last-line-filler" style="width: ${
elWidth - breakpoints.at(-1).cumulativeWidth
}px; display: inline-block;"></span>`;
element.classList.add("alinea-flowed");
}
};
Alinea.measure.ctx = Alinea.measure.canvas.getContext("2d");
documentReady(async () => {
Alinea.hyphen.dict["en-GB"] = (
await import("/sandbox/hyph/hyph-en-gb.js")
).default;
$$("p").forEach(para => {
Alinea.reflowElement(para);
});
});