sourcehypertextpublicsandboxhyphalinea.js

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,
							// How many characters are on either end of the hyphen.
							// Intended to prevent hyphenating words too early or too late.
							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
				}
			});
		}

		/* segments.push({
			text: "",
			type: "glue",
			width: 0,
			stretch: Infinity,
			shrink: 0,
			needsHyphen: false,
			node: node,
			log: {
				baseCost: 0
			}
		}); */

		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) {
				// Having short stubs at the end is worse than having them at the start
				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);

		// okay great
		// now we go through and add in a <br> element at each breakpoint
		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
			);
		});

		// now, for the very last line…
		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);
	});

	/* $$("p").forEach(paragraph => {
		Alinea.flowText(paragraph.innerText, paragraph);
		paragraph.classList.add("alinea-flowed");
	}); */
});