docs: every comment back inside the three-line rule

A sweep of the whole tree: 27 blocks ran to four lines or more. Trimmed
to what the code can't say, with two of them split back onto the
function each was really about.
This commit is contained in:
Steffen Becker committed 2026-08-11 21:22:13 +00:00
1 parent 2a8f0675bc
commit f055fc4850
13 files changed
+73 -110

No files matched your search

+5 -7
View File
@@ -106,10 +106,9 @@
type Run = { type?: string; attrs?: { height?: number; wrap?: string }; marks?: { type?: string; attrs?: { fontSize?: string; fontFamily?: string } }[] };
const para = doc.content?.[0] as { content?: Run[]; attrs?: { spaceBefore?: number; spaceAfter?: number; fontSize?: string; fontFamily?: string } } | undefined;
const inline = para?.content ?? [];
// The zone's biggest text run sizes its lines: LibreOffice grows the band to hold them
// when fo:min-height is smaller (probed, a 3-line header), and a zone set throughout in
// 10pt reserves 10pt — the body default only stands in for a run that declares none.
// A line is its font's own natural height, which is what the band renders at.
// A line is its font's own natural height: the zone's biggest run sizes its lines and
// LibreOffice grows the band to hold them where fo:min-height is smaller (probed).
// The body default only stands in for a run that declares no size of its own.
const line = (size?: string, family?: string) =>
size ? (parseFloat(size) * 96) / 72 * singleLineHeight(family) : HF_LINE_PX;
// The paragraph mark is the strut every run that declares no size of its own takes.
@@ -148,9 +147,8 @@
let effBottomRest = $derived(Math.max(mBottomPx, hfReachPx(footerDoc ?? null, footerDistPx, true), evenBottomReach));
let effBottomFirst = $derived(Math.max(mBottomPx, hfReachPx((differentFirstPage ? footerFirstDoc : footerDoc) ?? null, footerDistPx, true)));
// Per-section reaches for pageBreaks: "topFirst|topRest|bottomFirst|bottomRest" in px,
// one group per section, comma-separated. Section 1 repeats the four vars below.
// A section with page margins of its own (w:pgMar, its own ODF page layout) measures
// against those instead of the document's; `marginsFirst` is its first page's.
// one group per section, comma-separated. Section 1 repeats the four vars below; a
// section with page margins of its own measures against those (`marginsFirst` = page 1).
let sectionReach = $derived([
[effTopFirst, effTopRest, effBottomFirst, effBottomRest],
...extraHfSections.map((s) => {
+2 -2
View File
@@ -199,8 +199,6 @@
// below re-run on (the map's identity changes with each pagination pass).
type ZoneParams = [number, number, string, unknown];
// Lay out the zone's tabs: static HTML no ProseMirror plugin reaches. The advances are
// layout px, so only a content change invalidates them — not the zoom transform.
// The chapter a page runs under: the last heading at or above the field's outline
// level that has started by then (ODF text:chapter, Word STYLEREF).
function chapterOn(page: number, level: number): string {
@@ -212,6 +210,8 @@
return text;
}
// The zone's tabs: static HTML no ProseMirror plugin reaches. The advances are layout
// px, so only a content change invalidates them — not the zoom transform.
function layOutTabs(node: HTMLElement, _params: ZoneParams) {
const apply = () => layOutZoneTabs(node);
apply();
+4 -6
View File
@@ -1,10 +1,9 @@
import { Mark, mergeAttributes } from '@tiptap/core';
import type { Node as PMNode, Mark as PMMark } from '@tiptap/pm/model';
// A bookmark: a named range of text, the target a cross-reference or an internal link
// points at. A mark, not a point node — both formats store a range, and a REF field
// needs that range to recompute its text. Round-trips to ODF text:bookmark-start/-end
// and DOCX w:bookmarkStart/w:bookmarkEnd.
// A bookmark: a named range of text, the target a cross-reference or internal link points
// at. A mark, not a point node — both formats store a range (ODF text:bookmark-start/-end,
// DOCX w:bookmarkStart/-End) and a REF field needs it to recompute its text.
export type BookmarkRef = { name: string; from: number; to: number; text: string };
@@ -26,8 +25,7 @@ export function bookmarkNameOf(marks: readonly PMMark[]): string | null {
}
// Every bookmark range in document order. Adjacent text nodes sharing a name merge into
// one range; a name that reappears later (a paste, or across a paragraph boundary) gets
// its own entry and every lookup below takes the first.
// one range; a name that reappears later gets its own entry, and lookups take the first.
// ponytail: no cross-paragraph merge — the DOCX exporter can't write one either.
export function bookmarks(doc: PMNode): BookmarkRef[] {
const out: BookmarkRef[] = [];
+3 -8
View File
@@ -1,14 +1,9 @@
import { Extension } from '@tiptap/core';
import { DEFAULT_SHORTCUTS } from '../shortcuts';
// The text-flow attrs of a paragraph/heading. breakBefore: 'page' round-trips to ODF
// fo:break-before and DOCX w:pageBreakBefore; widowControl: false turns off the
// widow-orphan minimum (w:widowControl, fo:widows/fo:orphans); keepNext keeps the block
// on the page its successor starts on (w:keepNext, fo:keep-with-next) — headings do
// that anyway in both, so the attr only marks the other blocks; keepLines holds all of
// a block's lines on one page (w:keepLines, fo:keep-together). null = default;
// sectionBreak marks the first block of a new section, which carries its own
// header/footer set (storage/headerFooter.ts `HfSet`).
// The text-flow attrs of a paragraph/heading, null = default: breakBefore 'page', widow
// control, keepNext (a heading has it anyway, so the attr marks the other blocks),
// keepLines, and sectionBreak — which opens a section (storage/headerFooter.ts).
declare module '@tiptap/core' {
interface Commands<ReturnType> {
+11 -14
View File
@@ -258,11 +258,9 @@ export const PageBreaks = Extension.create({
let isUpdating = false;
let rafId: number | null = null;
let lastPlacementsKey = '';
// The layout before the current one. A block below a float's overhang moves further
// than the spacer the model accounts for, so two layouts can each imply the other —
// seeing the older one come back means a ping-pong, and the current one is kept.
// null, not '': that is the real key of a spacer-free layout, and a reset
// sharing it would read "all spacers removed" as a ping-pong and skip the dispatch.
// The layout before the current one: two layouts can each imply the other (a block
// below a float's overhang moves further than the model's spacer), so seeing the older
// key return means a ping-pong. null, not '' — that is a spacer-free layout's own key.
let prevPlacementsKey: string | null = null;
// Each pass measures against the spacers left by the previous pass, so a changed
// result needs one more pass to re-measure and settle (see calculate). Bounded so
@@ -535,11 +533,9 @@ export const PageBreaks = Extension.create({
return lines;
}
// `minLines` is the widow-orphan minimum, or 1 for a leaf taller than one page
// slot — there the rule is unsatisfiable and splitting beats overflowing.
// What a keep-with-next block has to fit below itself, in unscaled doc px: as
// many lines of the successor as widow-orphan control would keep together
// anyway. An empty block falls back to its whole height.
// What a keep-with-next block has to fit below itself, in unscaled doc px: as many
// lines of the successor as widow-orphan control would keep together anyway. An
// empty block falls back to its whole height.
function firstLinesHeight(el: HTMLElement, scale: number, want: number): number {
const lines = getLineRects(el);
if (!lines.length) return el.offsetHeight;
@@ -875,10 +871,9 @@ export const PageBreaks = Extension.create({
}
}
const cs = getComputedStyle(child);
// The block's own space above, and how much of it the last pass took off
// at a page top (the decoration zeroes both padding and margin).
// A cell's first block never carries it (LibreOffice adds none there), so
// the missing padding is the document, not a decoration to add back.
// The block's own space above, and how much of it the last pass took off at
// a page top. A cell's first block never carries any (LibreOffice adds none
// there), so missing padding is the document, not a decoration to add back.
const spaceAbove = parseFloat(cs.getPropertyValue('--space-before')) || 0;
const dropped = spaceAbove > 0.5 && !inTableCell
&& parseFloat(cs.paddingTop) < 0.5 && parseFloat(cs.marginTop) < 0.5
@@ -1193,6 +1188,8 @@ export const PageBreaks = Extension.create({
let extraShift = 0;
const guarded = leaf.naturalHeight <= CONTENT_HEIGHT
&& leaf.el.getAttribute('data-widow-control') !== 'false';
// Taller than a page slot: the widow-orphan rule is unsatisfiable there,
// and splitting beats overflowing.
const minLines = guarded ? MIN_KEPT_LINES : 1;
while (boundaryNatural < leaf.naturalHeight) {
const split = findLineSplit(leaf.el, boundaryNatural, scale, minLines);
+11 -16
View File
@@ -121,10 +121,9 @@ export function nextStopCm(x: number, stops: number[], interval: number): number
return Math.floor(x / interval + 1e-9) * interval + interval;
}
// A run of tabs wider than the line continues on the next one, as LibreOffice lays it
// out; Chromium instead hangs the leftover tabs and starts the next line at the margin.
// Walked from the pen BEFORE the run — the one thing a break of ours can't move — so the
// answer is the same whether or not the break is already in place.
// A run of tabs wider than the line continues on the next one, as LibreOffice lays it out
// (Chromium hangs the leftover tabs instead). Walked from the pen BEFORE the run — the one
// thing a break of ours can't move — so the answer holds with the break already in place.
function runBreaks(view: EditorView, blockEl: HTMLElement, tabs: number[], stops: TabStop[], scale: number, originX: number): number[] {
// Only a run of two or more can outgrow a line, and reading the block's geometry
// forces a reflow — so the ordinary single tab costs nothing here.
@@ -189,10 +188,8 @@ function measure(view: EditorView): TabLayout {
const blockEnd = pos + node.nodeSize - 1;
// Where the pen stands (cm from the text margin) after the tab before this one.
// Carrying it along the line is what makes one pass self-consistent: read from the
// DOM instead and every tab but the line's first measures the advance the *previous*
// pass gave the one before it, so the layout takes another pass to settle — and on a
// long document, where pagination keeps moving lines, it may never get a stable one.
// Carrying it along the line is what makes one pass self-consistent: read from the DOM
// instead and each tab measures the advance the previous pass gave the one before it.
let pen: number | null = null;
let lineTop = NaN;
for (let t = 0; t < tabs.length; t++) {
@@ -228,10 +225,9 @@ function measure(view: EditorView): TabLayout {
return { widths: out, breaks };
}
// Natural width of a doc range: the extent of each line it covers, added up — so a
// segment pushed onto the next line still reads its own width. Not the sum of the rects:
// a range crossing an inline element yields one for the element's box and one for the
// text inside it, and adding those counts the text twice.
// Natural width of a doc range: the extent of each line it covers, added up — so a segment
// pushed onto the next line still reads its own width. Not the sum of the rects: one comes
// from an inline element's box and one from the text in it, counting that text twice.
function rangeWidth(view: EditorView, from: number, to: number): number {
// Biased outwards, or the range swallows the tab's own span at either end.
const a = view.domAtPos(from, 1);
@@ -316,10 +312,9 @@ export function layOutZoneTabs(zone: HTMLElement): void {
const scale = para.offsetWidth ? rect.width / para.offsetWidth : 1;
const cs = getComputedStyle(para);
const padLeft = parseFloat(cs.paddingLeft || '0');
// The line's own width, not clientWidth: that is rounded up to whole px, and half a
// pixel of it is enough to wrap the run a right-aligned stop puts at the very end.
// One px of slack: a run ending exactly on the boundary wraps, and a stop a pixel
// short of it is a pixel nobody sees.
// The line's own width, not clientWidth: that is rounded up to whole px, and half a pixel
// is enough to wrap the run a right-aligned stop puts at the end. One px of slack, since
// a run ending exactly on the boundary wraps and a pixel short of it shows on nothing.
const lineCm = (rect.width / (scale || 1) - padLeft - parseFloat(cs.paddingRight || '0') - 1) / PX_PER_CM;
const stops = clampStops(parseTabStops(para.getAttribute('data-tab-stops')), lineCm);
if (!scale) { para.style.whiteSpace = wrapping; return; }
+6 -8
View File
@@ -998,10 +998,9 @@ function blocksToDocx(content: TiptapNode[], num: Numbering, contentWidthCm: num
textBoxes.push(textBoxDocxDescriptor(node));
out.push(new Paragraph({ children: [new TextRun({ text: `${TBX}${i}${TBX}` })] }));
} else if (node.type === 'tableOfContents') {
// A real, recognized TOC field (hyperlinked, over the index's own heading levels);
// Word/LibreOffice populate + link it on field update (features.updateFields does
// this on open). Title is a plain bold paragraph so it isn't itself listed, and is
// omitted where the index has none; our importer regenerates the node.
// A real TOC field over the index's own heading levels, populated and linked on
// field update (features.updateFields). The title is a plain bold paragraph so it
// isn't itself listed, and is omitted where the index has none.
const rawTitle = node.attrs?.title;
const tocTitle = typeof rawTitle === 'string' ? rawTitle : 'Table of Contents';
const depth = Number(node.attrs?.maxLevel);
@@ -1248,10 +1247,9 @@ export async function buildDocx(
const headerDist = Math.min(hf?.headerDistanceCm ?? HF_DISTANCE_CM, margins.top);
const footerDist = Math.min(hf?.footerDistanceCm ?? HF_DISTANCE_CM, margins.bottom);
// Page geometry rides every sectPr (Word requires it per section); the size is
// document-wide, the margins are the section's own where it has them. Fresh
// Header/Footer instances per section so each sectPr — including the body-final
// one our importer reads — carries its own references (Word's "Link to Previous").
// Page geometry rides every sectPr (Word requires it per section): the size is
// document-wide, the margins the section's own where it has them. Fresh Header/Footer
// instances per section, or a later one inherits ("Link to Previous").
const pagePropsFor = (i: number) => {
const m = setAt(i).margins ?? margins;
return {
+14 -25
View File
@@ -526,10 +526,9 @@ type CrossRefExport = { name: string; format: 'text' | 'page' };
const bookmarkNameOf = (node: TiptapNode): string =>
String(node.marks?.find((m) => m.type === 'bookmark')?.attrs?.name ?? '');
// Bracket each bookmark's text with the BMS/BME sentinels and replace every `crossRef`
// node with an XRF-sentinel run. Splicing into the run text (rather than into a run
// emitter) is what makes both ride every odf-kit path — cells and custom paragraphs
// included. applyBookmarks resolves them after serialization.
// Bracket each bookmark's text with the BMS/BME sentinels and every `crossRef` node with
// an XRF-sentinel run; applyBookmarks resolves them after serialization. Spliced into the
// run text, not a run emitter, so both ride every odf-kit path (cells included).
function replaceBookmarks(node: TiptapNode, refs: CrossRefExport[]): TiptapNode {
if (!node.content?.length) return node;
const content: TiptapNode[] = [];
@@ -925,10 +924,9 @@ function expandCellPadding(odtBytes: Uint8Array): Uint8Array {
return rezipOdt(files);
}
// A dragged table edge → the table style's fo:margin-left/-right + style:width (with
// odf-kit's table:align="margins" the table fills exactly what's left), and keep-rows
// → style:may-break-between-rows. odf-kit names table styles Table1, Table2, … in
// document order, matching the descriptor list.
// A dragged table edge → the style's fo:margin-left/-right + style:width (with odf-kit's
// table:align="margins" the table fills what's left); keep-rows → may-break-between-rows.
// odf-kit names table styles Table1, Table2, … in document order, as the descriptors are.
function applyTableProps(odtBytes: Uint8Array, margins: (TableProps | null)[], contentWidthCm: number): Uint8Array {
if (margins.every(m => m === null)) return odtBytes;
@@ -3229,11 +3227,8 @@ function contentsHeadingStyle(): string {
);
}
// The full <text:table-of-content>: a source (title + per-level entry templates that
// carry a right tab stop, page number, and link markers so LibreOffice rebuilds it as a
// linking TOC) plus a cached index-body (title + one entry paragraph per heading).
// A level's entry style: the file's own named one where the index came with it, else
// the automatic Contents_20_N this export mints.
// A level's entry style: the file's own named one where the index came with it, else the
// automatic Contents_20_N this export mints.
function tocLevelStyle(toc: TocExport, level: number): string {
const own = toc.levelStyles?.[level - 1];
return own ? odfStyleName(own) : `Contents_20_${level}`;
@@ -3274,12 +3269,9 @@ function tocXml(toc: TocExport, index: number): string {
return `<text:table-of-content text:name="${escapeXml(name)}" text:protected="true">${source}${body}</text:table-of-content>`;
}
// Resolve TOC sentinels: rewrite each marker <text:p>TOC{i}TOC</text:p> to its
// <text:table-of-content> and mint the Contents_20_* paragraph styles it references.
// BMS/BME/XRF sentinels → <text:bookmark-start/>, <text:bookmark-end/> and
// <text:bookmark-ref>. All three are legal anywhere in paragraph content, so a plain
// replace works wherever odf-kit put the run — inside a <text:span> included. Bookmark
// names are sanitized on insert, so nothing here needs XML escaping.
// replace works wherever odf-kit put the run — inside a <text:span> included.
function applyBookmarks(odtBytes: Uint8Array, refs: CrossRefExport[]): Uint8Array {
const files = unzipSync(odtBytes);
const contentBytes = files['content.xml'];
@@ -3623,8 +3615,6 @@ function hfAlign(para: TiptapNode): AlignValue | null {
return ta === 'center' || ta === 'right' || ta === 'justify' ? ta : null;
}
// fo:* paragraph-properties for a header/footer paragraph: alignment plus the paragraph
// background ("colored field") and per-side borders ("rule line"). Empty ⇒ no override.
// <style:tab-stops> for a zone paragraph: odf-kit builds the body's, but a header/footer
// style is written by hand here.
function tabStopsXml(attrs: TiptapNode['attrs']): string {
@@ -3640,7 +3630,8 @@ function tabStopsXml(attrs: TiptapNode['attrs']): string {
return `<style:tab-stops>${inner}</style:tab-stops>`;
}
// The whole <style:paragraph-properties> of a zone paragraph, or '' when it needs none.
// The whole <style:paragraph-properties> of a zone paragraph, or '' when it needs none:
// alignment plus the paragraph background ("colored field") and per-side borders.
function hfParaPropsXml(para: TiptapNode): string {
const props: string[] = [];
const align = hfAlign(para);
@@ -3771,11 +3762,9 @@ function ensureDrawNamespaces(styles: string): string {
return missing.length ? styles.replace(/<office:document-styles\b/, `<office:document-styles ${missing.join(' ')}`) : styles;
}
// Serialize a variant header/footer paragraph to <style:{header,footer}-{first,left}>
// XML (first = page 1, left = even pages). Runs and page fields become <text:span>
// referencing minted automatic text styles (pushed via `mint`); hardBreak → line-break.
// suffix null = the section's own <style:header>/<style:footer>; 'first'/'left' are the
// per-page variants ODF hangs beside it.
// A variant zone paragraph → <style:{header,footer}-{first,left}> XML (suffix null = the
// section's own zone). Runs and page fields become <text:span>s referencing minted
// automatic text styles (pushed via `mint`); hardBreak → line-break.
function hfVariantZoneXml(kind: 'header' | 'footer', suffix: 'first' | 'left' | null, para: TiptapNode, pageCount: number, mint: (styleXml: string) => void, prefix = ''): string {
let styleSeq = 0;
// Distinct minted-style prefix per variant so first + even styles never collide.
+6 -5
View File
@@ -967,9 +967,9 @@ function snapPt(v: number): number {
}
// ---- inline conversion (runs, marks, fields, images) -----------------------
// Word wraps a citation, a bibliography entry or any content control in a w:sdt whose
// content is ordinary inline content — walk through the wrapper, or the runs inside it
// (a citation's whole visible text) never reach the paragraph.
// Word wraps a citation or any content control in a w:sdt holding ordinary inline
// content — walk through the wrapper, or those runs never reach the paragraph.
function inlineChildren(el: Element): Element[] {
return Array.from(el.children).flatMap((c) => {
if (c.namespaceURI !== W || c.localName !== 'sdt') return [c];
@@ -1662,8 +1662,9 @@ function buildTable(tbl: Element, ctx: Ctx): Node | null {
// Table-level border defaults; per-cell w:tcBorders override per side. Edge cells
// fall back to the outer sides, interior cells to insideH/insideV.
// Word's w:tblStyle: only the name comes back — the look rides on the cell attrs, and
// the editor re-derives the regions from its own registry (refreshTableStyles).
// From w:tblStyle only the name comes back — the look rides on the cell attrs, and the
// editor re-derives the regions from its own registry (refreshTableStyles).
const styleEl = fc(fc(tbl, 'tblPr'), 'tblStyle');
const styleId = styleEl ? wVal(styleEl) : null;
const pad = docxCellPadding(tbl, styleId, ctx);
+1 -2
View File
@@ -52,8 +52,7 @@ export function wVal(el: Element): string | null {
// <w:tabs> → stops in cm from the left text margin, w:pos's origin (which the editor's
// attr shares). 'clear' removes an inherited stop and 'bar' is a rule, not a stop.
// w:leader → the fill character. "heavy" is a bold underscore, which we can only
// render as the plain one.
// w:leader → the fill character; "heavy" is a bold underscore we can only draw plain.
const DOCX_LEADER: Record<string, string> = {
dot: '.', hyphen: '-', underscore: '_', heavy: '_', middleDot: '·',
};
+4 -9
View File
@@ -146,10 +146,7 @@ function frameRotationDeg(el: Element): number {
return ((Math.round((-parseFloat(rot[1]) * 180) / Math.PI) % 360) + 360) % 360;
}
// Rotation + wrap attrs, shared by images, text boxes and shapes. A non-as-char anchor or
// an explicit style:wrap floats the element (free x/y collapse to the nearest side). An
// explicit as-char anchor stays inline: LibreOffice's Graphics style carries a style:wrap.
// Where an as-char frame sits against the line. `baseline`/`char` measure from the text
// Where an as-char frame sits against the line: `baseline`/`char` measure from the text
// baseline, `text`/`line` from the character area, and `from-top` puts the frame's top
// svg:y below the baseline whatever the relation says (all probed against LibreOffice).
function applyInlineVAlign(el: Element, attrs: Record<string, unknown>, gp: PropMap): void {
@@ -2009,11 +2006,9 @@ function convertTable(el: Element, ctx: Ctx): Node | null {
const colspan = parseInt(cellEl.getAttributeNS(NS.table, 'number-columns-spanned') ?? '1', 10) || 1;
const rowspan = parseInt(cellEl.getAttributeNS(NS.table, 'number-rows-spanned') ?? '1', 10) || 1;
const cellStyleName = cellEl.getAttributeNS(NS.table, 'style-name');
// ODF has no table-level cell margin — it sits on each cell's style, so the first
// cell's stands for the table and any cell that disagrees keeps its own.
// fo:padding defaults to 0 in ODF, so a side nobody declares really is zero —
// leaving it open would hand the cell Word's implicit 0.19cm instead (probed:
// LibreOffice sets an unstyled cell's text flush against the column edge).
// ODF has no table-level cell margin: it sits on each cell's style, and fo:padding
// defaults to 0, so a side nobody declares really is zero — left open, the cell
// would take Word's implicit 0.19cm instead (probed).
const padSides = ctx.resolver.cellPadding(cellStyleName).map((v) => v ?? 0);
if (cellPad === undefined) cellPad = cellPaddingAttr(padSides);
const ownPad = cellPaddingAttr(padSides, cellPad ?? DEFAULT_CELL_PADDING);
+3 -4
View File
@@ -81,10 +81,9 @@ export function layerTextProps(base: PropMap, over: PropMap): PropMap {
return Object.assign(out, over);
}
// LibreOffice holds a paragraph's two vertical margins in one item, so a style that
// declares just one of them does not inherit the other — the missing one falls back to
// the default paragraph style's value (probed: heading margins, and the space after a
// paragraph that only sets fo:margin-top).
// LibreOffice holds a paragraph's two vertical margins in one item, so a style declaring
// just one does not inherit the other: the missing one falls back to the default
// paragraph style's value (probed on heading margins).
const VERTICAL_MARGINS = ['fo:margin-top', 'fo:margin-bottom'] as const;
export function layerParaProps(base: PropMap, over: PropMap, poolDefaults: PropMap): PropMap {
+3 -4
View File
@@ -239,10 +239,9 @@ function compare(ref, ed) {
return issues;
}
// Whitespace-insensitive: spell decorations split text nodes mid-word and the two
// engines needn't agree on word boundaries — only on what sits on a line. A tab or
// index leader collapses to one token too: how many dots fill the gap is the fill's
// own business, and both engines end it at the same stop.
// Whitespace-insensitive: spell decorations split text nodes mid-word and the engines
// needn't agree on word boundaries, only on what sits on a line. A tab or index leader
// collapses to one token too — both engines end the fill at the same stop.
const norm = (s) => s.replace(/[\s\u00a0\u00ad]+/g, '').replace(/([.\u00b7_-])\1{2,}/g, '\u2026');
// ------------------------------------------------------------------- driver