source ⟩ hypertext ⟩ public ⟩ sandbox ⟩ hyph ⟩ alinea.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) => {
		const segmenter = new Intl.Segmenter(Alinea.settings.locale, {
			granularity: "word"
		});
		const segmentedString = [...segmenter.segment(string)]
			.map(word =>
				Alinea.hyphen.hyphenateWord(
					word.segment,
					Alinea.hyphen.dict[Alinea.settings.locale]
				)
			)
			.flatMap(word =>
				word.includes("\u00AD")
					? word.split("\u00AD").map((el, idx, arr) => ({
							segment: el,
							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 }
			);
		let segments = [];

		for (const el of segmentedString) {
			const isGlue = !!el.segment.match(/^\s+$/u);

			if (segments.length) {
				if (el.segment == "\u00AD") {
					segments.at(-1).needsHyphen = true;
					continue;
				}
				if (
					segments.at(-1).type == "box" &&
					!(
						isGlue ||
						Alinea.helpers.endsInHyphen(segments.at(-1)) ||
						segments.at(-1).text.match(/–—\//) ||
						el.segment.match(/–—/)
					)
				) {
					segments.at(-1).text = segments
						.at(-1)
						.text.concat(el.segment);
					segments.at(-1).width += Alinea.measure.width(
						el.segment,
						font
					);
					continue;
				}
			}

			const measuredWidth = Alinea.measure.width(el.segment, font);

			segments.push({
				text: el.segment,
				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,
				log: {
					baseCost: -1
				}
			});
		}

		segments.push({
			text: "",
			type: "glue",
			width: 0,
			stretch: Infinity,
			shrink: 0,
			needsHyphen: false,
			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"
		) {
			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;
	},
	flowText: (text, element) => {
		const elWidth = element.getBoundingClientRect().width;
		const elIndent = window
			.getComputedStyle(element)
			.getPropertyValue("text-indent")
			.slice(0, -2);

		const solved = Alinea.prettyLinebreak(
			Alinea.segmentText(text, Alinea.measure.getElementFont(element)),
			[elWidth - +elIndent, elWidth]
		);

		const htmlOutput = solved
			.map(line =>
				line
					.map((word, idx, arr) => {
						if (idx == arr.length - 1 && word.needsHyphen) {
							return `${word.text}-`;
						} else {
							return word.text;
						}
					})
					.join("")
			)
			.map((line, idx, arr) =>
				idx == arr.length - 1
					? `<span class="last-line">${line}</span>`
					: line
			)
			.join("<br>");

		element.innerHTML = htmlOutput;
	}
};

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(paragraph => {
		Alinea.flowText(paragraph.innerText, paragraph);
		paragraph.classList.add("alinea-flowed");
	});
});