diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..f0b52b5 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,57 @@ +# Changelog + +Short notes for changes on branch **`v2`** relative to **`main`** (no dedicated git version tag). For an exact file list, run: `git diff main...v2 --stat`. + +## [v2] — vs `main` + +### Added + +- **Multi-line alignment** — projects and preview support more than two lines; links only between vertically adjacent lines. +- **Line editor** — line cards (`LineCard`), modal text editing (`LineEditModal`), line settings (font, size, spacing, LTR/RTL, etc.) via popover/sheet. +- **Per-pair line controls** — vertical gap slider (`LinePairGapSlider`), toggling connector visibility for each adjacent line pair (`pairControls`). +- **Tokenization settings** — Tokens tab (word split characters, join character, optional punctuation tokenization); “?” hints (`SettingsFieldHint`). +- **Built-in examples** — expanded set in `examples.ts` (simple, Turkish interlinear with glosses/IPA, RTL Hebrew/Arabic/English, Tagalog with hyphen inside words, CJK). +- **`/about` page** — Aligner documentation, screenshots, table of contents, SEO; links from header and footer. +- **`/privacy` page** — dedicated route and navigation updates. +- **Raster export** — **2×–6×** scale for **PNG** and **PDF** (default 2×); tooltip explaining vector SVG/HTML. +- **Share** — export project data as JSON for presets and debugging (`ShareDialog`). +- **Custom fonts in exports** — **harfbuzzjs** for shaping (ligatures / OpenType closer to the browser), glyph outlines via **opentype.js** `glyph.getPath` at HarfBuzz glyph IDs (correct SVG orientation). +- **Domain & state** — `lines-helpers`, broader `schema`/project/link-selection plumbing, `layoutExport`, jump-to Tokens tab (`settingsNav`), editor UI (`editorUi`), `viewport` where needed. +- **Serialization** — state format evolution (including `compact-v3`, expanded `schema`), updated roundtrip and migration tests. +- **Tests** — token/palette coverage, harfbuzz export smoke; `docs/v2-manual-qa.md`. +- **Static assets** — screenshots for about, `sitemap.xml` updates. + +### Changed + +- **Home & SEO** — copy aligned with current UX (multi-line, adjacent links, terminology); Aligner (Bitext Align) branding; `SeoIntro`, `SeoSections`, `JsonLd`, `metadata.ts`. +- **Preview** — `AlignmentPreview`, token markup (`TokenView`, `TokenRow`), link layer (`AlignmentSvg`) with pairs and line order; line reorder and line actions; **in-preview controls follow preview light/dark background**, not only the page theme. +- **SVG export** — respects `pairControls`, background, line weight/opacity, optional embedded fonts, etc. (`svg.ts`, `ExportMenu`). +- **Settings** — Style / Colors / Tokens / Fonts tabs; icons (gear for editor tokenization settings, split-cells for Tokens); **Flowbite `Tabs`**: `classes.content` instead of deprecated `contentClass`. +- **Link palette** — when colors run out, the palette **cycles** (`palettes`). +- **OG image** — SVG generation tweaks for social previews (`og-svg.ts`). + +### Fixed + +- Ligatures and complex OpenType shaping for **user fonts** in PNG/PDF/SVG/HTML exports (HarfBuzz + opentype outlines). +- Incorrect glyph orientation when using only harfbuzzjs `glyphToPath` (outlines now come from opentype per shaped glyph). + +### Removed / replaced + +- Older narrow editor/preview pieces for a single-sentence model (`GlossInputRow`, `SentenceField`, `GlossRow`) — replaced by **multi-line cards** and token rows in preview. +- **Preview background image** — removed from Style settings (legacy shares / compact `bg:2` decode as light). + +### Dependencies (`bitext`) + +- Added: **`harfbuzzjs`** (^0.10.3). +- **`opentype.js`** pinned to **1.3.4** (avoid resolving to mistaken **1.3.5** or problematic **2.x** for exports). + +### Known limitations + +- **PDF** is still a **raster page** (PNG embedded in PDF), not pure vector SVG→PDF. +- Very large PNG/PDF scales increase file size and browser memory use. + +--- + +Commit range on this branch vs `main` (messages are terse): + +`d8a7796` … `9df3353` — multiple lines → new editor → merge better-editor → new interface / UI updates → about updates. diff --git a/TODO.md b/TODO.md index 625cdbc..3a3061d 100644 --- a/TODO.md +++ b/TODO.md @@ -1,6 +1,8 @@ +# Version 1.0.0 + - [x] Sort out glossing functionality - [x] Improve sharing visuals -- [ ] Check SEO +- [x] Check SEO - [x] Highlighting tokens when selecting (hovering) and when selected (with current color, which will be the link color) - [x] Make sure instructions are clear and concise, up to date, and complete. - [x] Add better examples - with complex links and advanced tokenization. @@ -9,3 +11,44 @@ - [x] Dependency CVE check (`npm audit` / CI) - [x] Add QR code export method - [-] Add QR code to visualization exports (small in the corner - only site link; `siteLandingQrDataUrl` + `siteQrPngDataUri` in svg — wiring disabled in ExportMenu) - out of scope for now + +# Version 2 + +Includes bugs and feature requests from the public. + +Feature requests - high priority: +- [x] Add ability to add more than 2 lines +- [x] Improve support for longer sentences - currently non-svg export is low resolution when font is small +- [x] add special separator to combine words into a single token - it will be connected with 1 line in the visualization but still be written with a space or spaces +- [x] add ability to optionally tokenize punctuation as separate tokens +- [x] add transcription line support (probably can be solved by adding more than 2 lines) + +Usability improvements - high priority: +- [x] Parameter card or other view should move to be next to the editor - currently on small screens you have to scroll back and forth between the editor and the parameters + +Bug fixes - high priority: +- [x] Reportedly ligatures in custom fonts are not working in the export (but fine in preview) — custom font `` → paths now uses **harfbuzzjs** for shaping + `glyphToPath`, with **opentype.js** 1.3.4 only for metrics / fallback. Pin stays on `opentype.js@1.3.4` (avoid mistaken 1.3.5 & 2.x). Remaining edge cases: exotic scripts / SVG `text-anchor` with RTL may still differ slightly from browser. +- [x] When color palette is depleted, it should cycle through the colors - currently uses the last color + +Advanced features - medium priority: + +- [x] Maybe parameter-line connection should be reworked to be more flexible - each line should have all the parameters configured separately. +- [x] Add ability to hide preview controls so that the user can see the entire visualization and screenshot it if needed. In this mode - add the credit to the bottom of the visualization, like in exports. + +General interface improvements - medium priority: + +- [x] Make interface more compact to accomodate more features +- [x] Probably add full screen mode for the preview - so that the user would be able to see it all and screenshot if needed - this will partially help if we won't be able to solve ligature problems +- [x] Create privacy policy page and link to it from the footer. We don't collect any data, but we should have a page for it. We use Google Analytics, Google Ads (probably in the future) and Tally for feedback. +- [x] Add alternative color marking - don't color the text, color the background of the token. This should correctly work on dark mode as well. The mode can be changed in the settings (probably in colors tab). + +Considerations: +- If we support multiple lines with independent parameters, we can deprecate separate gloss row and configuration - it will be just a single new line with the glosses. Then, the user would be able to add transcription and other annotations in the same manner. +- In case of adding multiple lines, additional lines after the first 2 should be optional. +- The ultimate fix for pdf export would be to use external resource like gotenberg. We can set up a server with it, but preferably this is to be avoided since it will add costs to support it. + + +Version 2.1: + +- [ ] Interface languages - add pages for some major languages +- [ ] Ability to create custom color palettes diff --git a/bitext/package-lock.json b/bitext/package-lock.json index a28420a..50b73e8 100644 --- a/bitext/package-lock.json +++ b/bitext/package-lock.json @@ -10,9 +10,10 @@ "dependencies": { "@resvg/resvg-js": "^2.6.2", "fflate": "^0.8.2", + "harfbuzzjs": "^0.10.3", "idb-keyval": "^6.2.1", "jspdf": "^4.2.1", - "opentype.js": "^1.3.4", + "opentype.js": "1.3.4", "qrcode": "^1.5.4" }, "devDependencies": { @@ -3146,6 +3147,12 @@ "dev": true, "license": "ISC" }, + "node_modules/harfbuzzjs": { + "version": "0.10.3", + "resolved": "https://registry.npmjs.org/harfbuzzjs/-/harfbuzzjs-0.10.3.tgz", + "integrity": "sha512-GJnLUrgLMadlMYrBGEXwYEimObbysy3prWT4HyPpFQERvgTU/OZ+ReUlEPOum6w4RBtFXzXiCCmECOr4sz3qwQ==", + "license": "MIT" + }, "node_modules/hasown": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz", diff --git a/bitext/package.json b/bitext/package.json index 9a4bd16..a4c860a 100644 --- a/bitext/package.json +++ b/bitext/package.json @@ -49,9 +49,10 @@ "dependencies": { "@resvg/resvg-js": "^2.6.2", "fflate": "^0.8.2", + "harfbuzzjs": "^0.10.3", "idb-keyval": "^6.2.1", "jspdf": "^4.2.1", - "opentype.js": "^1.3.4", + "opentype.js": "1.3.4", "qrcode": "^1.5.4" } } diff --git a/bitext/src/app.css b/bitext/src/app.css index 1a5689e..dc1b4bf 100644 --- a/bitext/src/app.css +++ b/bitext/src/app.css @@ -3,6 +3,11 @@ @import 'flowbite/src/themes/default.css'; @custom-variant dark (&:where(.dark, .dark *)); +/* Reserve scrollbar width so horizontal padding stays visually symmetric when scrollbars appear */ +html { + scrollbar-gutter: stable; +} + /* * Central theme: edit primary scale + app shell here. * Flowbite plugin forms/range use --color-brand*; Flowbite Svelte uses Tailwind `primary-*` for buttons/tabs. @@ -77,7 +82,6 @@ .preview-frame { position: relative; width: 100%; - max-width: 64rem; margin-inline: auto; border-radius: 0; border: 1px solid var(--color-card-border); @@ -97,18 +101,25 @@ border-color: rgb(55 65 81); } -.preview-frame__image-overlay { - position: absolute; - inset: 0; - backdrop-filter: blur(1px); -} - -.preview-frame--light .preview-frame__image-overlay { - background: color-mix(in srgb, #ffffff 82%, transparent); +/* Floating link hint — no panel chrome; halo keeps text readable on busy previews */ +.preview-frame__link-hint { + margin: 0; + border: none; + background: none; + box-shadow: none; + color: #374151; + text-shadow: + 0 0 6px #fff, + 0 0 14px #fff, + 0 1px 2px rgb(255 255 255 / 0.95); } -.preview-frame--dark .preview-frame__image-overlay { - background: color-mix(in srgb, #1e1e1e 82%, transparent); +.preview-frame--dark .preview-frame__link-hint { + color: #e5e7eb; + text-shadow: + 0 0 8px rgb(0 0 0 / 0.85), + 0 0 18px rgb(0 0 0 / 0.75), + 0 1px 3px rgb(0 0 0 / 0.9); } .preview-stack { @@ -119,6 +130,39 @@ align-items: stretch; } +.preview-frame__attribution { + position: relative; + z-index: 3; + margin-top: 1rem; + text-align: center; + font-family: var(--font-body, system-ui, sans-serif); + font-size: 0.75rem; + line-height: 1.4; + opacity: 0.72; +} + +.preview-frame--light .preview-frame__attribution { + color: #64748b; +} + +.preview-frame--dark .preview-frame__attribution { + color: #94a3b8; +} + +.preview-frame__attribution-link { + text-decoration: underline; + text-underline-offset: 2px; + cursor: pointer; +} + +.preview-frame--light .preview-frame__attribution-link { + color: #475569; +} + +.preview-frame--dark .preview-frame__attribution-link { + color: #cbd5e1; +} + .preview-gloss-wrap { width: 100%; display: flex; @@ -135,6 +179,15 @@ overflow: visible; } +/* Range between lines: tint follows preview background, not site dark mode */ +.preview-frame--light .line-gap-range input[type='range'] { + accent-color: rgb(79 70 229); +} + +.preview-frame--dark .line-gap-range input[type='range'] { + accent-color: rgb(165 180 252); +} + .preview-svg-layer .link-hit { pointer-events: none; } diff --git a/bitext/src/lib/brand.ts b/bitext/src/lib/brand.ts index e3fad5b..ef9f8e3 100644 --- a/bitext/src/lib/brand.ts +++ b/bitext/src/lib/brand.ts @@ -4,6 +4,9 @@ export const ALIGNER_SITE_URL = 'https://aligner.tinygods.dev'; /** Host label for attribution text in raster/SVG exports. */ export const ALIGNER_SITE_HOST = 'aligner.tinygods.dev'; +/** Plain attribution line (matches standalone SVG export footer text). */ +export const EXPORT_ATTRIBUTION_PLAIN = `Created with ${ALIGNER_SITE_HOST}`; + /** Google Analytics 4 measurement ID (gtag). */ export const GA_MEASUREMENT_ID = 'G-6Z5775NY39'; diff --git a/bitext/src/lib/components/editor/Editor.svelte b/bitext/src/lib/components/editor/Editor.svelte index a97cc24..9afab30 100644 --- a/bitext/src/lib/components/editor/Editor.svelte +++ b/bitext/src/lib/components/editor/Editor.svelte @@ -1,78 +1,93 @@ - -
-

+
+

-
- + + + + Line editor + + +
+

+ Whitespace splits words. + Extra split: {tok.extraSplitChars}. + Join: {tok.joinChars}. + Punctuation: {tok.punctuationChip}. +

-

- Edit the sentences here. To link words, click a word in the preview below, then click the - matching word on the other line — the connector will appear. You can link a word to multiple - words on the other side. Click a connector to remove it. Click a selected word again to deselect - it. -

-
-
- projectStore.setSourceText(v)} - /> -
-
- projectStore.setTargetText(v)} - /> -
-
- {#if settingsStore.settings.showGloss} -
-
-

Source glosses

- projectStore.setSourceGloss(id, v)} - /> -
-
-

Target glosses

- projectStore.setTargetGloss(id, v)} - /> + + {#if editorExpanded} +
+ {#each projectStore.lines as line, i (line.id)} + + {/each} +
+ + {#if projectStore.lines.length >= MAX_LINES} +

+ Soft limit: {MAX_LINES} lines — consider simplifying for shorter share links. +

+ {/if}
{/if} - + diff --git a/bitext/src/lib/components/editor/GlossInputRow.svelte b/bitext/src/lib/components/editor/GlossInputRow.svelte deleted file mode 100644 index c26d465..0000000 --- a/bitext/src/lib/components/editor/GlossInputRow.svelte +++ /dev/null @@ -1,42 +0,0 @@ - - -
- {#each tokens as t (t.id)} -
- - onGloss(t.id, (e.currentTarget as HTMLInputElement).value)} - /> -
- {/each} -
diff --git a/bitext/src/lib/components/editor/LineCard.svelte b/bitext/src/lib/components/editor/LineCard.svelte new file mode 100644 index 0000000..a611dc4 --- /dev/null +++ b/bitext/src/lib/components/editor/LineCard.svelte @@ -0,0 +1,118 @@ + + +
+
+ + + Line {index + 1} + +
+ + + + + projectStore.setLineText(line.id, (e.currentTarget as HTMLInputElement).value)} + /> + + + + + + + +
diff --git a/bitext/src/lib/components/editor/LineEditModal.svelte b/bitext/src/lib/components/editor/LineEditModal.svelte new file mode 100644 index 0000000..20010c9 --- /dev/null +++ b/bitext/src/lib/components/editor/LineEditModal.svelte @@ -0,0 +1,177 @@ + + += 0 ? `Edit line ${lineIndex + 1}` : 'Edit line'} + size="lg" +> + {#if currentLine} +

+ + Whitespace splits words. + Extra split characters: {tok.extraSplitChars}. + Join characters: {tok.joinChars}. + Tokenize punctuation: {tok.punctuationChip}. + Preview below. + + +

+ + +
+ +
+
+ {#each tokens as t (t.id)} + + {/each} +
+ {:else} +

No line selected.

+ {/if} + + {#snippet footer()} +
+ +
+ {/snippet} +
diff --git a/bitext/src/lib/components/editor/LineSettingsForm.svelte b/bitext/src/lib/components/editor/LineSettingsForm.svelte new file mode 100644 index 0000000..432c49b --- /dev/null +++ b/bitext/src/lib/components/editor/LineSettingsForm.svelte @@ -0,0 +1,278 @@ + + +
+
+

+ {popoverTitle} +

+ +
+ +
+
+ + +
+ {#if line.font.source === 'google'} +
+ + +
+ {:else} +
+ + +
+ {/if} +
+ + {#if line.font.source === 'custom'} +
+ +
+ {/if} + +
+
+ + + projectStore.updateLineStyle(line.id, { + textSizePx: Number((e.currentTarget as HTMLInputElement).value) + })} + /> +
+
+ + + projectStore.updateLineStyle(line.id, { + gapWordPx: Number((e.currentTarget as HTMLInputElement).value) + })} + /> +
+
+ +
+ +
+ + {#if nextLine} +
+ +
+ {/if} +
diff --git a/bitext/src/lib/components/editor/LineSettingsPopover.svelte b/bitext/src/lib/components/editor/LineSettingsPopover.svelte new file mode 100644 index 0000000..61f632f --- /dev/null +++ b/bitext/src/lib/components/editor/LineSettingsPopover.svelte @@ -0,0 +1,56 @@ + + + +
+ +
+
diff --git a/bitext/src/lib/components/editor/LineSettingsSheet.svelte b/bitext/src/lib/components/editor/LineSettingsSheet.svelte new file mode 100644 index 0000000..536d7bc --- /dev/null +++ b/bitext/src/lib/components/editor/LineSettingsSheet.svelte @@ -0,0 +1,73 @@ + + +{#if viewportStore.isNarrow && line && index >= 0} + +
+
+ +
+
+ +
+
+
+{/if} diff --git a/bitext/src/lib/components/editor/SentenceField.svelte b/bitext/src/lib/components/editor/SentenceField.svelte deleted file mode 100644 index fbdcefc..0000000 --- a/bitext/src/lib/components/editor/SentenceField.svelte +++ /dev/null @@ -1,75 +0,0 @@ - - -
- - -
- {#each tokens as t (t.id)} - - {/each} -
-
diff --git a/bitext/src/lib/components/editor/TokenChip.svelte b/bitext/src/lib/components/editor/TokenChip.svelte index 0467745..2655e76 100644 --- a/bitext/src/lib/components/editor/TokenChip.svelte +++ b/bitext/src/lib/components/editor/TokenChip.svelte @@ -1,12 +1,10 @@ @@ -32,57 +38,116 @@
- {#if bg === 'image' && settingsStore.settings.backgroundImageDataUrl} -
+ {#if selectionStore.showLinkHint()} + {/if}
- {#if showSourceGloss} -
- -
- {/if}
- +
+ {#each projectStore.lines as line, li (line.id)} + {@const gearDomId = `${instancePrefix}-line-gear-${line.id}`} + {@const pending = selectionStore.pending} + {@const rowDimmed = + pending != null && !lineIsLinkTargetWhilePending(lineIds, pending.lineId, line.id)} +
+
+ +
+
+ +
+
+ +
+
+ {#if li < projectStore.lines.length - 1} + {@const lowerLine = projectStore.lines[li + 1]!} +
+ +
+ {/if} + {/each}
- +
- {#if showTargetGloss} -
- -
+ {#if hideChrome} +

+ Created with + +

{/if}
- +
diff --git a/bitext/src/lib/components/preview/AlignmentSvg.svelte b/bitext/src/lib/components/preview/AlignmentSvg.svelte index 29582bc..3b59c97 100644 --- a/bitext/src/lib/components/preview/AlignmentSvg.svelte +++ b/bitext/src/lib/components/preview/AlignmentSvg.svelte @@ -1,21 +1,49 @@ - - - diff --git a/bitext/src/lib/components/preview/LinePairGapSlider.svelte b/bitext/src/lib/components/preview/LinePairGapSlider.svelte new file mode 100644 index 0000000..b019f4f --- /dev/null +++ b/bitext/src/lib/components/preview/LinePairGapSlider.svelte @@ -0,0 +1,60 @@ + + + +
+
+ + +
+
+ {gapPx}px + + projectStore.setLinePairGap( + upperLineId, + lowerLineId, + Number((e.currentTarget as HTMLInputElement).value) + )} + /> +
+
+
+
diff --git a/bitext/src/lib/components/preview/LineReorderButtons.svelte b/bitext/src/lib/components/preview/LineReorderButtons.svelte new file mode 100644 index 0000000..fec0564 --- /dev/null +++ b/bitext/src/lib/components/preview/LineReorderButtons.svelte @@ -0,0 +1,48 @@ + + +
+ + +
diff --git a/bitext/src/lib/components/preview/LineTrailingActions.svelte b/bitext/src/lib/components/preview/LineTrailingActions.svelte new file mode 100644 index 0000000..47ca73c --- /dev/null +++ b/bitext/src/lib/components/preview/LineTrailingActions.svelte @@ -0,0 +1,60 @@ + + +
+ + + {#if !viewportStore.isNarrow} + + {/if} +
diff --git a/bitext/src/lib/components/preview/PreviewFontLoader.svelte b/bitext/src/lib/components/preview/PreviewFontLoader.svelte index 33a2578..d2e4304 100644 --- a/bitext/src/lib/components/preview/PreviewFontLoader.svelte +++ b/bitext/src/lib/components/preview/PreviewFontLoader.svelte @@ -1,46 +1,32 @@ diff --git a/bitext/src/lib/components/preview/TokenRow.svelte b/bitext/src/lib/components/preview/TokenRow.svelte index 75a27e1..ef85f8e 100644 --- a/bitext/src/lib/components/preview/TokenRow.svelte +++ b/bitext/src/lib/components/preview/TokenRow.svelte @@ -1,33 +1,38 @@ -
+
{#each tokens as t, i (t.id)} {@const nextTok = tokens[i + 1]} import type { Token } from '$lib/domain/tokens.js'; - import { pendingAlignmentColor, primaryLinkForToken } from '$lib/domain/alignment.js'; + import { pendingAlignmentColor, primaryConnectionForToken } from '$lib/domain/alignment.js'; import { settingsStore } from '$lib/state/settings.svelte.js'; import { projectStore } from '$lib/state/project.svelte.js'; import { selectionStore } from '$lib/state/selection.svelte.js'; let { token, - side, + lineId, + textSizePx, showNumber, index, interactive = false, @@ -15,75 +16,80 @@ joinTightEnd = false }: { token: Token; - side: 'source' | 'target'; + lineId: string; + textSizePx: number; showNumber: boolean; index: number; interactive?: boolean; - /** Strip inline padding on the start when `token.joinLeft` (alt. separator split). */ joinTightStart?: boolean; - /** Strip inline padding on the end when the next token is joined to this one. */ joinTightEnd?: boolean; } = $props(); let hovering = $state(false); - const sz = $derived( - side === 'source' - ? settingsStore.settings.sourceTextSizePx - : settingsStore.settings.targetTextSizePx - ); const palette = $derived(settingsStore.settings.palette); + const connections = $derived(projectStore.connections); + const lineIds = $derived(projectStore.lines.map((l) => l.id)); + const pending = $derived(selectionStore.pending); + const conn = $derived.by(() => primaryConnectionForToken(connections, token.id)); - const links = $derived(projectStore.links); - const link = $derived.by(() => primaryLinkForToken(links, token.id)); + const linkBgMode = $derived( + settingsStore.settings.colorTokensByLink && + settingsStore.settings.tokenLinkColorMode === 'background' + ); + + const previewSurfaceHex = $derived( + settingsStore.settings.background === 'dark' ? '#1e1e1e' : '#ffffff' + ); const textColor = $derived.by(() => { - if (!settingsStore.settings.colorTokensByLink || !link?.color) return null; - return link.color; + if (!settingsStore.settings.colorTokensByLink || !conn?.color || linkBgMode) return null; + return conn.color; }); - const selectedSource = $derived(selectionStore.selectedSource); - const selectedTarget = $derived(selectionStore.selectedTarget); - - const isSelected = $derived.by(() => - side === 'source' ? selectedSource.has(token.id) : selectedTarget.has(token.id) - ); + const isPinned = $derived(pending != null && pending.tokenId === token.id); const accentColor = $derived.by(() => { if (!interactive) return null; - const src = [...selectedSource]; - const tgt = [...selectedTarget]; - - if (isSelected) { - return pendingAlignmentColor(links, src, tgt, palette); + if (isPinned) { + return pendingAlignmentColor(connections, [token.id], [], palette); } if (!hovering) return null; - - if (src.length && tgt.length === 0 && side === 'target') { - return pendingAlignmentColor(links, src, [token.id], palette); + const pend = pending; + if (!pend) { + return pendingAlignmentColor(connections, [token.id], [], palette); } - if (tgt.length && src.length === 0 && side === 'source') { - return pendingAlignmentColor(links, [token.id], tgt, palette); + if (pend.lineId === lineId) { + return pendingAlignmentColor(connections, [token.id], [], palette); } - if (src.length === 0 && tgt.length === 0) { - return side === 'source' - ? pendingAlignmentColor(links, [token.id], [], palette) - : pendingAlignmentColor(links, [], [token.id], palette); + const ip = lineIds.indexOf(pend.lineId); + const it = lineIds.indexOf(lineId); + if (ip < 0 || it < 0) return null; + if (Math.abs(ip - it) !== 1) { + return pendingAlignmentColor(connections, [token.id], [], palette); } - if (src.length && side === 'source') { - return pendingAlignmentColor(links, [token.id], [], palette); + const upperTok = ip < it ? pend.tokenId : token.id; + const lowerTok = ip < it ? token.id : pend.tokenId; + return pendingAlignmentColor(connections, [upperTok], [lowerTok], palette); + }); + + const linkFillBackground = $derived.by(() => { + if (!settingsStore.settings.colorTokensByLink || !linkBgMode) return null; + const surf = previewSurfaceHex; + if (interactive && accentColor != null && (isPinned || hovering)) { + return `color-mix(in srgb, ${accentColor} ${isPinned ? 48 : 38}%, ${surf})`; } - if (tgt.length && side === 'target') { - return pendingAlignmentColor(links, [], [token.id], palette); + if (conn?.color) { + return `color-mix(in srgb, ${conn.color} 28%, ${surf})`; } return null; }); - const displayColor = $derived(accentColor ?? textColor ?? undefined); + const displayColor = $derived(linkBgMode ? undefined : (accentColor ?? textColor ?? undefined)); function onClick() { if (!interactive) return; - selectionStore.previewTokenClick(side, token.id); + selectionStore.previewTokenClick(lineId, token.id); } @@ -93,13 +99,14 @@ class="token-view token-view--clickable" class:token-view--join-before={joinTightStart} class:token-view--join-after={joinTightEnd} - class:token-view--colored={textColor && !accentColor} - class:token-view--accent-sel={accentColor !== null && isSelected} - class:token-view--accent-hover={accentColor !== null && !isSelected} + class:token-view--colored={!linkBgMode && textColor && !accentColor} + class:token-view--accent-sel={!linkBgMode && accentColor !== null && isPinned} + class:token-view--accent-hover={!linkBgMode && accentColor !== null && !isPinned} data-token-id={token.id} - data-side={side} - style:font-size="{sz}px" + data-line={lineId} + style:font-size="{textSizePx}px" style:--token-accent={accentColor ?? 'transparent'} + style:background={linkFillBackground ?? undefined} style:color={displayColor} onclick={onClick} onmouseenter={() => { @@ -119,11 +126,12 @@ class="token-view" class:token-view--join-before={joinTightStart} class:token-view--join-after={joinTightEnd} - class:token-view--colored={textColor} + class:token-view--colored={!linkBgMode && !!textColor} data-token-id={token.id} - data-side={side} - style:font-size="{sz}px" - style:color={textColor ?? undefined} + data-line={lineId} + style:font-size="{textSizePx}px" + style:background={linkFillBackground ?? undefined} + style:color={linkBgMode ? undefined : (textColor ?? undefined)} > {#if showNumber} {index + 1} diff --git a/bitext/src/lib/components/seo/JsonLd.svelte b/bitext/src/lib/components/seo/JsonLd.svelte index b7eb3d6..f39cf0c 100644 --- a/bitext/src/lib/components/seo/JsonLd.svelte +++ b/bitext/src/lib/components/seo/JsonLd.svelte @@ -36,7 +36,7 @@ name: 'Can I align phrases, not just single words?', acceptedAnswer: { '@type': 'Answer', - text: 'Yes. Select several words on one side before clicking the matching word or phrase on the other. The tool supports one-to-one, one-to-many, and many-to-many links, which often reflects real translations better than a strict one-word mapping.' + text: 'Yes. Each link joins two word-sized boxes on neighboring lines. You can add several links from the same word to different partners (one-to-many or many-to-one) by clicking that word again and choosing another match on the adjacent row. To treat two written words as a single box—for example a fixed expression—use the join character under Settings → Tokens.' } }, { @@ -44,7 +44,7 @@ name: 'Is this a full machine translator?', acceptedAnswer: { '@type': 'Answer', - text: 'No. You provide both sentences — the tool does not translate them for you. The value is in the visualization and the manual control over which words count as matches.' + text: 'No. You type or paste the text yourself—the app does not translate it for you. The value is in the visualization and the manual control over which words count as matches.' } }, { @@ -52,7 +52,7 @@ name: 'Can I export the alignment as an image?', acceptedAnswer: { '@type': 'Answer', - text: 'Yes. PNG, SVG, PDF, and a self-contained HTML file are all supported, along with a shareable link that encodes both sentences, every connector, and your visual settings.' + text: 'Yes. PNG, SVG, PDF, and a self-contained HTML file are all supported, along with a shareable link that encodes every line of text, every connector, and your visual settings.' } } ] diff --git a/bitext/src/lib/components/seo/SeoIntro.svelte b/bitext/src/lib/components/seo/SeoIntro.svelte index d72ba4f..8203ba8 100644 --- a/bitext/src/lib/components/seo/SeoIntro.svelte +++ b/bitext/src/lib/components/seo/SeoIntro.svelte @@ -1,15 +1,20 @@

What this tool does

- This page is a free word-by-word translation visualizer: a small utility for showing which words - in a sentence correspond to which words in its translation. Type the source on one line and the - translation on the other, click a word in the preview, then click its match on the other side, - and a connector is drawn between them. Linguists call this a word alignment. Most people just - want to see which words match without learning the term. + Aligner (also labeled Bitext Align) + is a free word-by-word translation visualizer: it shows which words in one line correspond to which + words on the next. Type or paste text in the line editor, add more rows with + Add line + when you want glosses, IPA, or another tier—then put lines in the order you need. In the + preview, click a word, then click + a match on the line directly above or below; + only those adjacent rows link, so reorder lines with the arrows if something is out of reach. + Linguists call this a word alignment; most people just want to see which words match.

diff --git a/bitext/src/lib/components/seo/SeoSections.svelte b/bitext/src/lib/components/seo/SeoSections.svelte index f10d2a9..af50e13 100644 --- a/bitext/src/lib/components/seo/SeoSections.svelte +++ b/bitext/src/lib/components/seo/SeoSections.svelte @@ -1,5 +1,5 @@

Guides

@@ -30,11 +30,11 @@

For conlangers, the visualizer is a lightweight way to show how a constructed language maps onto - English in a specific example. Turn on the - interlinear gloss rows to label - morphemes above and below the sentences, use the alternative token separators to split agglutinating - forms into their parts, and export the result for a blog post, a forum thread, or a conlang community - share. + English in a specific example. Add extra lines for + glosses or IPA (and stack them next + to the sentences they annotate), tune how text splits into word-sized boxes under + Settings → Tokens, and export the + result for a blog post, a forum thread, or a conlang community share.

@@ -83,16 +83,19 @@ Can I align phrases, not just single words?

- Yes. Select several words on one side before clicking the matching word or phrase on the other. - The tool supports one-to-one, one-to-many, and many-to-many links, which often reflects real - translations better than a strict one-word mapping. + Yes. Each link joins two word-sized boxes on neighboring lines. You can add several links from the + same word to different partners (one-to-many or many-to-one) by clicking that word again and choosing + another match on the adjacent row. To treat two written words as a single box—for example a fixed + expression—use the join character under Settings → Tokens.

Is this a full machine translator?

- No. You provide both sentences — the tool does not translate them for you. The value is in the + No. You type or paste the text yourself—the app does not translate it for you. The value is in the visualization and the manual control over which words count as matches.

@@ -100,7 +103,7 @@ Can I export the alignment as an image?

- Yes. PNG, SVG, PDF, and a self-contained HTML file are all supported, along with a shareable - link that encodes both sentences, every connector, and your visual settings. + Yes. PNG, SVG, PDF, and a self-contained HTML file are all supported, along with a shareable link + that encodes every line of text, every connector, and your visual settings.

diff --git a/bitext/src/lib/components/settings/AppearanceTab.svelte b/bitext/src/lib/components/settings/AppearanceTab.svelte index fbafc61..ca4c1fc 100644 --- a/bitext/src/lib/components/settings/AppearanceTab.svelte +++ b/bitext/src/lib/components/settings/AppearanceTab.svelte @@ -1,152 +1,15 @@
-
- - -
-
- - -
- {#if s.background === 'image'} -
- - { - const f = (e.currentTarget as HTMLInputElement).files?.[0]; - if (!f) return; - const dataUrl = await new Promise((res, rej) => { - const r = new FileReader(); - r.onload = () => res(String(r.result)); - r.onerror = () => rej(new Error('read')); - r.readAsDataURL(f); - }); - settingsStore.patch({ backgroundImageDataUrl: dataUrl }); - }} - /> -
- {/if} -
- - - settingsStore.patch({ - sourceTextSizePx: Number((e.currentTarget as HTMLInputElement).value) - })} - /> -
-
- - - settingsStore.patch({ - targetTextSizePx: Number((e.currentTarget as HTMLInputElement).value) - })} - /> -
-
- - - settingsStore.patch({ - glossTextSizePx: Number((e.currentTarget as HTMLInputElement).value) - })} - /> -
-
- - - settingsStore.patch({ gapWordPx: Number((e.currentTarget as HTMLInputElement).value) })} - /> -
-
- - - settingsStore.patch({ gapLinePx: Number((e.currentTarget as HTMLInputElement).value) })} - /> -
+
+ + +
- settingsStore.patch({ - sourceFontSource: (e.currentTarget as HTMLSelectElement).value as 'google' | 'custom' - })} - > - - - +
+ +
- {#if s.sourceFontSource === 'google'} -
- - + {#if status} +
+ {status}
- {:else} -
- - onCustomFile('source', e)} - /> -
- {#if s.sourceCustomFontName} -
-

- Loaded: {s.sourceCustomFontName} -

-
- {/if} {/if} - -
-

Target line

-
-
- - -
- {#if s.targetFontSource === 'google'} -
- - -
- {:else} -
- - onCustomFile('target', e)} - /> -
- {#if s.targetCustomFontName} -
-

- Loaded: {s.targetCustomFontName} -

-
+ {/if} - {/if} - -
-

Gloss line

-

- Interlinear glosses (preview, editor, export). Independent of the source and target script - fonts. -

-
-
- -
- {#if s.glossFontSource === 'google'} -
- - -
- {:else} -
- - onCustomFile('gloss', e)} - /> -
- {#if s.glossCustomFontName} -
-

- Loaded: {s.glossCustomFontName} -

-
- {/if} - {/if}
diff --git a/bitext/src/lib/components/settings/LinguisticsTab.svelte b/bitext/src/lib/components/settings/LinguisticsTab.svelte index e8abb53..bd29197 100644 --- a/bitext/src/lib/components/settings/LinguisticsTab.svelte +++ b/bitext/src/lib/components/settings/LinguisticsTab.svelte @@ -1,13 +1,15 @@
-
+

- Interlinear gloss row + Token numbers

-

- Source glosses above the source line, target glosses below the target line (when filled) -

+
+
- -

- Distance between each gloss row and its sentence. Default ≈ 1.5× gloss line height. -

- - settingsStore.patch({ - glossLineGapPx: Number((e.currentTarget as HTMLInputElement).value) - })} - /> -
-
+

+ Advanced tokenization +

+ +
+
+ + +
+ updateTokenSplitChars((e.currentTarget as HTMLInputElement).value)} + /> +
+ +
+
+ + +
+ updateTokenMergeChar((e.currentTarget as HTMLInputElement).value)} + /> +
+
-
-

- Token numbers -

-

- Show indices on each word in the preview -

+
+

+ Split punctuation +

+
-
-
-
-
- - Advanced tokenization - + + {#if s.tokenSplitPunctuation}
-

- Whitespace always splits tokens. Add extra separator characters to also split inside words - (for example: .- makes - cat.s - and - cat-s - become - cat + - s). -

- +
+ + +
updateTokenSplitChars((e.currentTarget as HTMLInputElement).value)} + placeholder={'Leave empty for Unicode \\p{P}; or e.g. ,.;:!?'} + value={s.tokenPunctuationChars} + oninput={(e) => updateTokenPunctuationChars((e.currentTarget as HTMLInputElement).value)} />
-
+ {/if}
diff --git a/bitext/src/lib/components/settings/SettingsFieldHint.svelte b/bitext/src/lib/components/settings/SettingsFieldHint.svelte new file mode 100644 index 0000000..d5d3493 --- /dev/null +++ b/bitext/src/lib/components/settings/SettingsFieldHint.svelte @@ -0,0 +1,20 @@ + + + + + + diff --git a/bitext/src/lib/components/settings/SettingsPanel.svelte b/bitext/src/lib/components/settings/SettingsPanel.svelte index 9e3d1cc..4422343 100644 --- a/bitext/src/lib/components/settings/SettingsPanel.svelte +++ b/bitext/src/lib/components/settings/SettingsPanel.svelte @@ -2,21 +2,48 @@ import { AdjustmentsHorizontalSolid, FontFamilyOutline, - LanguageOutline, - PaletteSolid + PaletteSolid, + SplitCellsOutline } from 'flowbite-svelte-icons'; import { Card, TabItem, Tabs } from 'flowbite-svelte'; import AppearanceTab from './AppearanceTab.svelte'; import ColorsTab from './ColorsTab.svelte'; import LinguisticsTab from './LinguisticsTab.svelte'; import FontsTab from './FontsTab.svelte'; + import { settingsNavStore } from '$lib/state/settingsNav.svelte.js'; let selected = $state('appearance'); + let lastTokensFocusGeneration = 0; + + $effect(() => { + const gen = settingsNavStore.tokensFocusGeneration; + if (gen <= lastTokensFocusGeneration) return; + lastTokensFocusGeneration = gen; + selected = 'linguistics'; + queueMicrotask(() => { + document.getElementById('settings-panel')?.scrollIntoView({ + behavior: 'smooth', + block: 'start' + }); + }); + }); - -

Settings

- + +

+ Settings +

+ {#snippet titleSlot()} {/snippet} -
- -
+
{#snippet titleSlot()} @@ -38,34 +63,28 @@ - + {#snippet titleSlot()} - Linguistics - {/snippet} -
- -
+
{#snippet titleSlot()} - + Fonts {/snippet} -
- -
+
diff --git a/bitext/src/lib/components/share/CopyLinkButton.svelte b/bitext/src/lib/components/share/CopyLinkButton.svelte index d62e329..8d73ed1 100644 --- a/bitext/src/lib/components/share/CopyLinkButton.svelte +++ b/bitext/src/lib/components/share/CopyLinkButton.svelte @@ -1,14 +1,14 @@ +
+ + + +
+ diff --git a/bitext/src/lib/components/share/ShareDialog.svelte b/bitext/src/lib/components/share/ShareDialog.svelte index e329124..803a68f 100644 --- a/bitext/src/lib/components/share/ShareDialog.svelte +++ b/bitext/src/lib/components/share/ShareDialog.svelte @@ -1,8 +1,14 @@ @@ -127,6 +177,14 @@ Download QR (PNG) {/if} +
diff --git a/bitext/src/lib/components/share/ShareQuickRow.svelte b/bitext/src/lib/components/share/ShareQuickRow.svelte index 25de282..626397f 100644 --- a/bitext/src/lib/components/share/ShareQuickRow.svelte +++ b/bitext/src/lib/components/share/ShareQuickRow.svelte @@ -5,7 +5,7 @@ import CopyLinkButton from './CopyLinkButton.svelte'; import ShareDialog from './ShareDialog.svelte'; import { encodeState } from '$lib/serialization/encode.js'; - import { SCHEMA_VERSION, type AppStateV1 } from '$lib/serialization/schema.js'; + import { SCHEMA_VERSION, type AppStateV2 } from '$lib/serialization/schema.js'; import { projectStore } from '$lib/state/project.svelte.js'; import { settingsStore } from '$lib/state/settings.svelte.js'; import { getShareUrl } from '$lib/share/url.js'; @@ -16,7 +16,7 @@ const shareTitle = 'Word-by-word translation visualizer'; const shareUrl = $derived.by(() => { - const state: AppStateV1 = { + const state: AppStateV2 = { v: SCHEMA_VERSION, project: projectStore.getSnapshot(), settings: { ...settingsStore.settings } @@ -33,7 +33,7 @@ ); async function webShare() { - const state: AppStateV1 = { + const state: AppStateV2 = { v: SCHEMA_VERSION, project: projectStore.getSnapshot(), settings: { ...settingsStore.settings } @@ -50,7 +50,7 @@ 'inline-flex h-14 w-14 shrink-0 items-center justify-center rounded-none border-0 bg-transparent text-primary-600 transition-colors hover:bg-primary-50 hover:text-primary-700 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary-600 dark:text-primary-400 dark:hover:bg-primary-900/40 dark:hover:text-primary-300'; - +

Share

Copy a link with your alignment in the URL, or share to social media. diff --git a/bitext/src/lib/domain/alignment.ts b/bitext/src/lib/domain/alignment.ts index d860ef4..06adfe9 100644 --- a/bitext/src/lib/domain/alignment.ts +++ b/bitext/src/lib/domain/alignment.ts @@ -1,65 +1,89 @@ import type { TokenId } from './tokens.js'; -import { connectedLinkIds } from './link-graph.js'; +import { connectedConnectionIds } from './link-graph.js'; import { pickUnusedPaletteColor, type PaletteName } from './palettes.js'; -/** One source–target pair per object so 1-to-many is multiple links (same or different colors). */ -export interface Link { +/** One adjacency pair per object; many-to-many concepts use multiple connections (same or different colors). */ +export interface Connection { id: string; - sourceId: TokenId; - targetId: TokenId; + upperTokenId: TokenId; + lowerTokenId: TokenId; color?: string; } -let linkCounter = 0; +let connectionCounter = 0; -export function createLinkId(): string { - linkCounter += 1; - return `link-${Date.now()}-${linkCounter}`; +export function createConnectionId(): string { + connectionCounter += 1; + return `conn-${Date.now()}-${connectionCounter}`; } -export function addAtomicLinks( - links: Link[], - pairs: { sourceId: string; targetId: string }[], +export function addAtomicConnections( + connections: Connection[], + pairs: { upperTokenId: string; lowerTokenId: string }[], color: string -): Link[] { - const existing = new Set(links.map((l) => `${l.sourceId}\0${l.targetId}`)); - const next = [...links]; - for (const { sourceId, targetId } of pairs) { - const key = `${sourceId}\0${targetId}`; +): Connection[] { + const existing = new Set(connections.map((l) => `${l.upperTokenId}\0${l.lowerTokenId}`)); + const next = [...connections]; + for (const { upperTokenId, lowerTokenId } of pairs) { + const key = `${upperTokenId}\0${lowerTokenId}`; if (existing.has(key)) continue; existing.add(key); - next.push({ id: createLinkId(), sourceId, targetId, color }); + next.push({ id: createConnectionId(), upperTokenId, lowerTokenId, color }); } return next; } -export function removeLink(links: Link[], linkId: string): Link[] { - return links.filter((l) => l.id !== linkId); +/** Convenience for s-* / t-* legacy pairs (source row above target). */ +export function addAtomicLinks( + connections: Connection[], + pairs: { sourceId: string; targetId: string }[], + color: string +): Connection[] { + return addAtomicConnections( + connections, + pairs.map((p) => ({ upperTokenId: p.sourceId, lowerTokenId: p.targetId })), + color + ); } -export function findLinksForToken(links: Link[], tokenId: TokenId): Link[] { - return links.filter((l) => l.sourceId === tokenId || l.targetId === tokenId); +export function removeConnection(connections: Connection[], connectionId: string): Connection[] { + return connections.filter((l) => l.id !== connectionId); } -export function linkForId(links: Link[], linkId: string): Link | undefined { - return links.find((l) => l.id === linkId); +export function findConnectionsForToken(connections: Connection[], tokenId: TokenId): Connection[] { + return connections.filter((l) => l.upperTokenId === tokenId || l.lowerTokenId === tokenId); } -/** First link involving this token (for token tint when multiple links share a token). */ -export function primaryLinkForToken(links: Link[], tokenId: TokenId): Link | undefined { - return findLinksForToken(links, tokenId)[0]; +export function connectionForId( + connections: Connection[], + connectionId: string +): Connection | undefined { + return connections.find((l) => l.id === connectionId); } -/** Color `addAlignment` would assign for this token set (preview / selection highlight). */ +/** @deprecated use connectionForId */ +export const linkForId = connectionForId; + +export function primaryConnectionForToken( + connections: Connection[], + tokenId: TokenId +): Connection | undefined { + return findConnectionsForToken(connections, tokenId)[0]; +} + +/** @deprecated Prefer primaryConnectionForToken */ +export const primaryLinkForToken = primaryConnectionForToken; + +/** Color batch alignment would assign for this token set (preview / selection highlight). */ export function pendingAlignmentColor( - links: Link[], - sourceIds: string[], - targetIds: string[], + connections: Connection[], + upperTokenIds: string[], + lowerTokenIds: string[], palette: PaletteName ): string { - const seedTokens = new Set([...sourceIds, ...targetIds]); - const componentBefore = connectedLinkIds(links, seedTokens); - const used = new Set(links.map((l) => l.color).filter((c): c is string => Boolean(c))); - const inherited = links.find((l) => componentBefore.has(l.id) && l.color)?.color; - return inherited ?? pickUnusedPaletteColor(palette, used); + const seedTokens = new Set([...upperTokenIds, ...lowerTokenIds]); + const componentBefore = connectedConnectionIds(connections, seedTokens); + const used = new Set(connections.map((l) => l.color).filter((c): c is string => Boolean(c))); + const inherited = connections.find((l) => componentBefore.has(l.id) && l.color)?.color; + return inherited ?? pickUnusedPaletteColor(palette, used, connections.length); } diff --git a/bitext/src/lib/domain/lines-helpers.ts b/bitext/src/lib/domain/lines-helpers.ts new file mode 100644 index 0000000..5ac0157 --- /dev/null +++ b/bitext/src/lib/domain/lines-helpers.ts @@ -0,0 +1,137 @@ +import type { Connection } from '$lib/domain/alignment.js'; +import { tokenize, reconcile, type Token, type TokenizeOptions } from '$lib/domain/tokens.js'; +import type { LineV2, PairControlV2 } from '$lib/serialization/schema.js'; + +export function lineOrderTokenIds(lines: LineV2[], opts: TokenizeOptions): Map { + const m = new Map(); + for (const line of lines) { + for (const t of tokenize(line.rawText, line.id, opts)) { + m.set(t.id, line.id); + } + } + return m; +} + +/** Undirected adjacency: keys `a\0b` with a,b adjacent in stack order (either orientation). */ +export function adjacentLineKeys(lineIds: string[]): Set { + const s = new Set(); + for (let i = 0; i < lineIds.length - 1; i++) { + const a = lineIds[i]!; + const b = lineIds[i + 1]!; + s.add(`${a}\0${b}`); + s.add(`${b}\0${a}`); + } + return s; +} + +export function tokensAreAdjacentLines( + upperTokenId: string, + lowerTokenId: string, + tokenToLine: Map, + adjKeys: Set +): boolean { + const lu = tokenToLine.get(upperTokenId); + const ll = tokenToLine.get(lowerTokenId); + if (lu == null || ll == null) return false; + return adjKeys.has(`${lu}\0${ll}`); +} + +export function filterConnectionsByAdjacency( + connections: Connection[], + lines: LineV2[], + opts: TokenizeOptions +): Connection[] { + const tokenToLine = lineOrderTokenIds(lines, opts); + const lineIds = lines.map((l) => l.id); + const adj = adjacentLineKeys(lineIds); + const tokens = new Set(tokenToLine.keys()); + return connections.filter( + (c) => + tokens.has(c.upperTokenId) && + tokens.has(c.lowerTokenId) && + tokensAreAdjacentLines(c.upperTokenId, c.lowerTokenId, tokenToLine, adj) + ); +} + +export function showConnectorsForPair( + pairControls: PairControlV2[], + upperLineId: string, + lowerLineId: string +): boolean { + return !pairControls.some((p) => p.upperLineId === upperLineId && p.lowerLineId === lowerLineId); +} + +/** Canonical key: upper line is earlier in `lineIds` array than lower. */ +export function canonicalPair( + lineIds: string[], + lineA: string, + lineB: string +): { upperLineId: string; lowerLineId: string } | null { + const ia = lineIds.indexOf(lineA); + const ib = lineIds.indexOf(lineB); + if (ia < 0 || ib < 0 || Math.abs(ia - ib) !== 1) return null; + return ia < ib + ? { upperLineId: lineA, lowerLineId: lineB } + : { upperLineId: lineB, lowerLineId: lineA }; +} + +export function reconcileLineTokens( + prev: Token[] | undefined, + line: LineV2, + opts: TokenizeOptions +): Token[] { + const next = tokenize(line.rawText, line.id, opts); + if (!prev) return next; + return reconcile(prev, next, line.id); +} + +/** Line id prefix of token id (`l-abc-3` → `l-abc`). */ +export function tokenLineId(tokenId: string): string { + const i = tokenId.lastIndexOf('-'); + return i > 0 ? tokenId.slice(0, i) : tokenId; +} + +/** + * While a token is pinned for linking, these are the line ids the user may click on + * (that line and its vertical neighbors in the stack). + */ +export function lineIsLinkTargetWhilePending( + lineIds: string[], + pendingLineId: string, + rowLineId: string +): boolean { + const i = lineIds.indexOf(pendingLineId); + if (i < 0) return false; + if (rowLineId === pendingLineId) return true; + if (i > 0 && rowLineId === lineIds[i - 1]) return true; + if (i + 1 < lineIds.length && rowLineId === lineIds[i + 1]) return true; + return false; +} + +/** + * While a token is selected for linking (`pendingLineId`), only connectors on the pair(s) + * that include that line and a vertically adjacent line are "active"; others are dimmed in the UI. + */ +export function connectionIsActiveForPendingSelection( + lineIds: string[], + conn: Connection, + pendingLineId: string +): boolean { + const a = tokenLineId(conn.upperTokenId); + const b = tokenLineId(conn.lowerTokenId); + return ( + lineIsLinkTargetWhilePending(lineIds, pendingLineId, a) && + lineIsLinkTargetWhilePending(lineIds, pendingLineId, b) + ); +} + +/** True when `upper` is directly above `lower` in the current line stack. */ +export function isStackedAdjacentPair( + lines: LineV2[], + upperLineId: string, + lowerLineId: string +): boolean { + const i = lines.findIndex((l) => l.id === upperLineId); + const j = lines.findIndex((l) => l.id === lowerLineId); + return i >= 0 && j === i + 1; +} diff --git a/bitext/src/lib/domain/link-geometry.ts b/bitext/src/lib/domain/link-geometry.ts index d489466..bb40bcb 100644 --- a/bitext/src/lib/domain/link-geometry.ts +++ b/bitext/src/lib/domain/link-geometry.ts @@ -5,26 +5,26 @@ const PAD = 8; /** * Endpoints sit outside token boxes so strokes do not cross glyph bounds. - * Uses row order (source row above target in typical layout). + * upperToken layout is treated as the higher (smaller cy) row when rows differ. */ export function linkEndpoints( - pSource: TokenLayout, - pTarget: TokenLayout + pUpper: TokenLayout, + pLower: TokenLayout ): { x1: number; y1: number; x2: number; y2: number } { - const sourceAbove = pSource.cy <= pTarget.cy; - if (sourceAbove) { + const upperIsAbove = pUpper.cy <= pLower.cy; + if (upperIsAbove) { return { - x1: pSource.cx, - y1: pSource.y + pSource.h + PAD, - x2: pTarget.cx, - y2: pTarget.y - PAD + x1: pUpper.cx, + y1: pUpper.y + pUpper.h + PAD, + x2: pLower.cx, + y2: pLower.y - PAD }; } return { - x1: pSource.cx, - y1: pSource.y - PAD, - x2: pTarget.cx, - y2: pTarget.y + pTarget.h + PAD + x1: pUpper.cx, + y1: pUpper.y - PAD, + x2: pLower.cx, + y2: pLower.y + pLower.h + PAD }; } diff --git a/bitext/src/lib/domain/link-graph.ts b/bitext/src/lib/domain/link-graph.ts index ee45f13..d170bbe 100644 --- a/bitext/src/lib/domain/link-graph.ts +++ b/bitext/src/lib/domain/link-graph.ts @@ -1,57 +1,87 @@ -import type { Link } from './alignment.js'; +import type { Connection } from './alignment.js'; +/** Keep only connections whose endpoints are in the allowed token-id set and form an adjacent line pair. */ +export function filterValidConnections( + connections: Connection[], + tokenIds: Set, + tokenIdToLineId: Map, + adjacentLinePairs: Set +): Connection[] { + return connections.filter((c) => { + if (!tokenIds.has(c.upperTokenId) || !tokenIds.has(c.lowerTokenId)) return false; + const u = tokenIdToLineId.get(c.upperTokenId); + const l = tokenIdToLineId.get(c.lowerTokenId); + if (u == null || l == null) return false; + const key1 = `${u}\0${l}`; + const key2 = `${l}\0${u}`; + return adjacentLinePairs.has(key1) || adjacentLinePairs.has(key2); + }); +} + +/** @deprecated v1 bipartite filter */ export function filterValidLinks( - links: Link[], - sourceIds: Set, - targetIds: Set -): Link[] { - return links.filter((l) => sourceIds.has(l.sourceId) && targetIds.has(l.targetId)); + connections: Connection[], + _sourceIds: Set, + _targetIds: Set +): Connection[] { + return connections.filter( + (c) => _sourceIds.has(c.upperTokenId) && _targetIds.has(c.lowerTokenId) + ); } -/** BFS over token–link adjacency; returns link ids in the connected component seeded by `seedTokenIds`. */ -export function connectedLinkIds(links: Link[], seedTokenIds: Iterable): Set { - const byToken = new Map(); - for (const link of links) { - const src = byToken.get(link.sourceId) ?? []; - src.push(link); - byToken.set(link.sourceId, src); - const tgt = byToken.get(link.targetId) ?? []; - tgt.push(link); - byToken.set(link.targetId, tgt); +/** BFS over token–connection adjacency; returns connection ids in the component seeded by `seedTokenIds`. */ +export function connectedConnectionIds( + connections: Connection[], + seedTokenIds: Iterable +): Set { + const byToken = new Map(); + for (const conn of connections) { + const up = byToken.get(conn.upperTokenId) ?? []; + up.push(conn); + byToken.set(conn.upperTokenId, up); + const lo = byToken.get(conn.lowerTokenId) ?? []; + lo.push(conn); + byToken.set(conn.lowerTokenId, lo); } const seenTokens = new Set(); - const seenLinks = new Set(); + const seenConns = new Set(); const queue = [...seedTokenIds]; for (const id of queue) seenTokens.add(id); while (queue.length > 0) { const tokenId = queue.shift(); if (!tokenId) continue; - for (const link of byToken.get(tokenId) ?? []) { - if (!seenLinks.has(link.id)) seenLinks.add(link.id); - if (!seenTokens.has(link.sourceId)) { - seenTokens.add(link.sourceId); - queue.push(link.sourceId); + for (const conn of byToken.get(tokenId) ?? []) { + if (!seenConns.has(conn.id)) seenConns.add(conn.id); + if (!seenTokens.has(conn.upperTokenId)) { + seenTokens.add(conn.upperTokenId); + queue.push(conn.upperTokenId); } - if (!seenTokens.has(link.targetId)) { - seenTokens.add(link.targetId); - queue.push(link.targetId); + if (!seenTokens.has(conn.lowerTokenId)) { + seenTokens.add(conn.lowerTokenId); + queue.push(conn.lowerTokenId); } } } - return seenLinks; + return seenConns; } -export function connectedLinkComponents(links: Link[]): Set[] { +/** @deprecated */ +export const connectedLinkIds = connectedConnectionIds; + +export function connectedConnectionComponents(connections: Connection[]): Set[] { const seen = new Set(); const out: Set[] = []; - for (const link of links) { - if (seen.has(link.id)) continue; - const comp = connectedLinkIds(links, [link.sourceId, link.targetId]); + for (const conn of connections) { + if (seen.has(conn.id)) continue; + const comp = connectedConnectionIds(connections, [conn.upperTokenId, conn.lowerTokenId]); for (const id of comp) seen.add(id); out.push(comp); } return out; } + +/** @deprecated */ +export const connectedLinkComponents = connectedConnectionComponents; diff --git a/bitext/src/lib/domain/palettes.test.ts b/bitext/src/lib/domain/palettes.test.ts new file mode 100644 index 0000000..9a7d4c7 --- /dev/null +++ b/bitext/src/lib/domain/palettes.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, it } from 'vitest'; +import { PALETTES, pickUnusedPaletteColor } from './palettes.js'; + +describe('pickUnusedPaletteColor', () => { + it('returns first unused color when palette not exhausted', () => { + const used = new Set([PALETTES.pastel[0]!, PALETTES.pastel[1]!]); + expect(pickUnusedPaletteColor('pastel', used, 99)).toBe(PALETTES.pastel[2]); + }); + + it('cycles when every palette color is already used', () => { + const colors = PALETTES.pastel; + const used = new Set(colors); + expect(pickUnusedPaletteColor('pastel', used, 0)).toBe(colors[0]); + expect(pickUnusedPaletteColor('pastel', used, 1)).toBe(colors[1]); + expect(pickUnusedPaletteColor('pastel', used, colors.length)).toBe(colors[0]); + expect(pickUnusedPaletteColor('pastel', used, colors.length + 2)).toBe(colors[2]); + }); +}); diff --git a/bitext/src/lib/domain/palettes.ts b/bitext/src/lib/domain/palettes.ts index 7cafbdb..d531cae 100644 --- a/bitext/src/lib/domain/palettes.ts +++ b/bitext/src/lib/domain/palettes.ts @@ -6,13 +6,20 @@ export const PALETTES: Record = { academic: ['#64748b', '#475569', '#334155', '#78716c', '#57534e', '#71717a', '#52525b', '#3f3f46'] }; -/** Next palette color that is not yet used; when all are used, cycle without bias to one hue first. */ -export function pickUnusedPaletteColor(palette: PaletteName, usedHex: ReadonlySet): string { +/** + * Next palette color not yet present in `usedHex`. When every palette color already appears somewhere, + * cycles with `cycleOrdinal` (e.g. `connections.length` before adding the next link). + */ +export function pickUnusedPaletteColor( + palette: PaletteName, + usedHex: ReadonlySet, + cycleOrdinal = 0 +): string { const colors = PALETTES[palette]; for (const c of colors) { if (!usedHex.has(c)) return c; } - return colors[usedHex.size % colors.length]; + return colors[cycleOrdinal % colors.length]!; } /** Assign `count` colors in order, reusing only after the palette is exhausted. */ diff --git a/bitext/src/lib/domain/tokenization-summary.ts b/bitext/src/lib/domain/tokenization-summary.ts new file mode 100644 index 0000000..6d4a0fa --- /dev/null +++ b/bitext/src/lib/domain/tokenization-summary.ts @@ -0,0 +1,20 @@ +import type { VisualSettingsV2 } from '$lib/serialization/schema.js'; + +/** Values shown in gray chips next to tokenization labels (editor hint). */ +export function editorTokenizationChipValues(s: VisualSettingsV2): { + extraSplitChars: string; + joinChars: string; + punctuationChip: string; +} { + const extraSplitChars = s.tokenSplitChars.length > 0 ? s.tokenSplitChars : 'none'; + const joinChars = s.tokenMergeChar || 'none'; + let punctuationChip: string; + if (!s.tokenSplitPunctuation) { + punctuationChip = 'off'; + } else if (s.tokenPunctuationChars.length > 0) { + punctuationChip = s.tokenPunctuationChars; + } else { + punctuationChip = '\\p{P}'; + } + return { extraSplitChars, joinChars, punctuationChip }; +} diff --git a/bitext/src/lib/domain/tokens.test.ts b/bitext/src/lib/domain/tokens.test.ts new file mode 100644 index 0000000..e3e04e9 --- /dev/null +++ b/bitext/src/lib/domain/tokens.test.ts @@ -0,0 +1,104 @@ +import { describe, expect, it } from 'vitest'; +import { tokenize, tokenizeOptionsFromVisualSettings } from './tokens.js'; + +const base = () => + tokenizeOptionsFromVisualSettings({ + tokenSplitChars: '', + tokenMergeChar: '', + tokenSplitPunctuation: false, + tokenPunctuationChars: '' + }); + +describe('tokenize', () => { + it('splits on whitespace', () => { + const t = tokenize(' a bb ', 'L', base()); + expect(t.map((x) => x.text)).toEqual(['a', 'bb']); + }); + + it('splits on splitChars with joinLeft on following token', () => { + const o = tokenizeOptionsFromVisualSettings({ + tokenSplitChars: '.-', + tokenMergeChar: '', + tokenSplitPunctuation: false, + tokenPunctuationChars: '' + }); + const t = tokenize('a.b-c', 'L', o); + expect(t.map((x) => [x.text, x.joinLeft])).toEqual([ + ['a', false], + ['b', true], + ['c', true] + ]); + }); + + it('joins with merge char and shows spaces in text', () => { + const o = tokenizeOptionsFromVisualSettings({ + tokenSplitChars: '', + tokenMergeChar: '+', + tokenSplitPunctuation: false, + tokenPunctuationChars: '' + }); + const t = tokenize('hello+world x', 'L', o); + expect(t.map((x) => x.text)).toEqual(['hello world', 'x']); + expect(t[0]!.joinLeft).toBe(false); + expect(t[1]!.joinLeft).toBe(false); + }); + + it('does not treat merge char as split when also in split list (merge removed in settings)', () => { + const o = tokenizeOptionsFromVisualSettings({ + tokenSplitChars: '.', + tokenMergeChar: '+', + tokenSplitPunctuation: false, + tokenPunctuationChars: '' + }); + const t = tokenize('a+b.c', 'L', o); + expect(t.map((x) => x.text)).toEqual(['a b', 'c']); + }); + + it('splits punctuation when enabled', () => { + const o = tokenizeOptionsFromVisualSettings({ + tokenSplitChars: '', + tokenMergeChar: '', + tokenSplitPunctuation: true, + tokenPunctuationChars: '' + }); + const t = tokenize('Hi, there!', 'L', o); + expect(t.map((x) => x.text)).toEqual(['Hi', ',', 'there', '!']); + }); + + it('keeps apostrophe inside Latin letters', () => { + const o = tokenizeOptionsFromVisualSettings({ + tokenSplitChars: '', + tokenMergeChar: '', + tokenSplitPunctuation: true, + tokenPunctuationChars: '' + }); + const t = tokenize("don't", 'L', o); + expect(t.map((x) => x.text)).toEqual(["don't"]); + }); + + it('combines merge and punctuation split', () => { + const o = tokenizeOptionsFromVisualSettings({ + tokenSplitChars: '', + tokenMergeChar: '+', + tokenSplitPunctuation: true, + tokenPunctuationChars: '' + }); + const t = tokenize('foo+bar,baz', 'L', o); + expect(t.map((x) => x.text)).toEqual(['foo bar', ',', 'baz']); + }); + + it('custom punctuation list only splits listed characters', () => { + const o = tokenizeOptionsFromVisualSettings({ + tokenSplitChars: '', + tokenMergeChar: '', + tokenSplitPunctuation: true, + tokenPunctuationChars: ',.' + }); + const t = tokenize('Hi, a.', 'L', o); + expect(t.map((x) => x.text)).toEqual(['Hi', ',', 'a', '.']); + const t2 = tokenize('Hi!there', 'L', o); + expect(t2.map((x) => x.text)).toEqual(['Hi!there']); + const t3 = tokenize('Hi,there', 'L', o); + expect(t3.map((x) => x.text)).toEqual(['Hi', ',', 'there']); + }); +}); diff --git a/bitext/src/lib/domain/tokens.ts b/bitext/src/lib/domain/tokens.ts index 279ce6c..5457b0c 100644 --- a/bitext/src/lib/domain/tokens.ts +++ b/bitext/src/lib/domain/tokens.ts @@ -5,7 +5,19 @@ export interface Token { text: string; /** No visible space before this token in preview/editor rows. */ joinLeft?: boolean; - gloss?: string; +} + +/** Options passed to {@link tokenize}; build with {@link tokenizeOptionsFromVisualSettings}. */ +export interface TokenizeOptions { + splitChars: string; + /** Single non-whitespace character; empty disables word-join. */ + mergeChar: string; + splitPunctuation: boolean; + /** + * When `splitPunctuation` is true: empty means Unicode `\p{P}`; non-empty means only these + * codepoints split as punctuation tokens. + */ + punctuationChars: string; } function uniqChars(input: string): string[] { @@ -20,73 +32,157 @@ function uniqChars(input: string): string[] { return out; } -/** Split on whitespace and custom one-char separators (like "." or "-"). */ -export function tokenize(raw: string, side: 'source' | 'target', splitChars = ''): Token[] { +const PUNCT_CLASS = /\p{P}/u; + +function apostropheBetweenLetters( + ch: string, + prev: string | undefined, + next: string | undefined +): boolean { + return Boolean( + (ch === "'" || ch === '\u2019') && prev && next && /\p{L}/u.test(prev) && /\p{L}/u.test(next) + ); +} + +function shouldSplitPunctuation( + ch: string, + prev: string | undefined, + next: string | undefined, + punctuationSplitSet: Set | null +): boolean { + const isPunct = punctuationSplitSet ? punctuationSplitSet.has(ch) : PUNCT_CLASS.test(ch); + if (!isPunct) return false; + if (apostropheBetweenLetters(ch, prev, next)) return false; + return true; +} + +function expandMerge(segment: string, mergeChar: string): string { + if (!mergeChar || !segment.includes(mergeChar)) return segment; + return segment.split(mergeChar).join(' '); +} + +function emitPiecesFromExpanded( + expanded: string, + firstJoinLeft: boolean, + splitPunctuation: boolean, + punctuationSplitSet: Set | null +): Array> { + if (!expanded) return []; + if (!splitPunctuation) { + return [{ text: expanded, joinLeft: firstJoinLeft }]; + } + + const out: Array> = []; + let buf = ''; + + const pushBuf = (joinLeft: boolean) => { + if (!buf) return; + out.push({ text: buf, joinLeft }); + buf = ''; + }; + + for (let i = 0; i < expanded.length; i++) { + const ch = expanded[i]!; + const prev = i > 0 ? expanded[i - 1] : undefined; + const next = i + 1 < expanded.length ? expanded[i + 1] : undefined; + if (shouldSplitPunctuation(ch, prev, next, punctuationSplitSet)) { + const firstWord = out.length === 0; + pushBuf(firstWord ? firstJoinLeft : true); + out.push({ text: ch, joinLeft: true }); + } else { + buf += ch; + } + } + pushBuf(out.length === 0 ? firstJoinLeft : true); + return out; +} + +export function tokenizeOptionsFromVisualSettings(s: { + tokenSplitChars: string; + tokenMergeChar?: string; + tokenSplitPunctuation?: boolean; + tokenPunctuationChars?: string; +}): TokenizeOptions { + return { + splitChars: s.tokenSplitChars, + mergeChar: s.tokenMergeChar ?? '', + splitPunctuation: Boolean(s.tokenSplitPunctuation), + punctuationChars: s.tokenPunctuationChars ?? '' + }; +} + +/** Non-null set when using a custom punctuation list; null means Unicode `\p{P}`. */ +export function punctuationSplitSetForOptions(opts: TokenizeOptions): Set | null { + if (!opts.splitPunctuation) return null; + if (!opts.punctuationChars) return null; + return new Set([...opts.punctuationChars]); +} + +/** + * Split on whitespace and optional one-char split characters. + * Optional merge character joins parts of one token; merge positions become spaces in `text`. + * Optional punctuation splitting: default Unicode `\p{P}`, or a custom character list; apostrophe + * between letters is kept inside the word. + */ +export function tokenize(raw: string, lineId: string, opts: TokenizeOptions): Token[] { const cleaned = raw.trim(); if (!cleaned) return []; - const prefix = side === 'source' ? 's' : 't'; - const separators = new Set(uniqChars(splitChars)); + const splitSet = new Set(uniqChars(opts.splitChars)); + const mergeChar = opts.mergeChar; + const punctSet = punctuationSplitSetForOptions(opts); + const out: Token[] = []; let cur = ''; let nextJoinLeft = false; - const pushCurrent = () => { + const pushSegment = () => { if (!cur) return; - out.push({ - id: `${prefix}-${out.length}`, - text: cur, - joinLeft: out.length > 0 ? nextJoinLeft : false, - gloss: undefined - }); + const expanded = expandMerge(cur, mergeChar); + const pieces = emitPiecesFromExpanded(expanded, nextJoinLeft, opts.splitPunctuation, punctSet); + for (const p of pieces) { + out.push({ + id: `${lineId}-${out.length}`, + text: p.text, + joinLeft: p.joinLeft + }); + } cur = ''; nextJoinLeft = false; }; for (const ch of cleaned) { if (/\s/u.test(ch)) { - pushCurrent(); - nextJoinLeft = false; + pushSegment(); continue; } - if (separators.has(ch)) { - pushCurrent(); + if (splitSet.has(ch)) { + pushSegment(); nextJoinLeft = true; continue; } cur += ch; } - pushCurrent(); + pushSegment(); return out; } /** - * Preserve token ids/glosses by index when text edits add/remove words. + * Preserve token ids when text edits add/remove words. */ export function reconcile( prev: Token[], nextTokens: Array>, - side: 'source' | 'target' + lineId: string ): Token[] { - const prefix = side === 'source' ? 's' : 't'; return nextTokens.map((next, i) => { const old = prev[i]; if (old) { return { ...old, text: next.text, joinLeft: next.joinLeft }; } return { - id: `${prefix}-${i}`, + id: `${lineId}-${i}`, text: next.text, - joinLeft: next.joinLeft, - gloss: undefined + joinLeft: next.joinLeft }; }); } - -/** True if the phrase has at least one non-empty gloss (for conditional gloss rows in the preview). */ -export function phraseHasAnyGloss(tokens: Token[]): boolean { - return tokens.some((t) => (t.gloss?.trim() ?? '').length > 0); -} - -export function textsFromTokens(tokens: Token[]): string { - return tokens.map((t) => t.text).join(' '); -} diff --git a/bitext/src/lib/export/pdf.ts b/bitext/src/lib/export/pdf.ts index e0ecca0..16b8e6e 100644 --- a/bitext/src/lib/export/pdf.ts +++ b/bitext/src/lib/export/pdf.ts @@ -2,8 +2,8 @@ import { jsPDF } from 'jspdf'; import { svgStringToCanvas } from './svg-raster.js'; /** Raster PDF (PNG page) — reliable in Vite; no svg2pdf.js / CJS interop. */ -export async function svgStringToPdfBlob(svg: string): Promise { - const { canvas, cssWidth, cssHeight } = await svgStringToCanvas(svg, 2); +export async function svgStringToPdfBlob(svg: string, scale = 2): Promise { + const { canvas, cssWidth, cssHeight } = await svgStringToCanvas(svg, scale); const w = cssWidth; const h = cssHeight; const pngData = canvas.toDataURL('image/png'); diff --git a/bitext/src/lib/export/svg.ts b/bitext/src/lib/export/svg.ts index 0960815..b483d73 100644 --- a/bitext/src/lib/export/svg.ts +++ b/bitext/src/lib/export/svg.ts @@ -1,36 +1,76 @@ -import { ALIGNER_SITE_HOST } from '$lib/brand.js'; +import { ALIGNER_SITE_HOST, EXPORT_ATTRIBUTION_PLAIN } from '$lib/brand.js'; import type { Token } from '$lib/domain/tokens.js'; import type { TokenLayout } from '$lib/types/layout.js'; -import type { Link } from '$lib/domain/alignment.js'; -import { primaryLinkForToken } from '$lib/domain/alignment.js'; +import type { Connection } from '$lib/domain/alignment.js'; +import { primaryConnectionForToken } from '$lib/domain/alignment.js'; +import { canonicalPair, showConnectorsForPair, tokenLineId } from '$lib/domain/lines-helpers.js'; +import type { PairControlV2, TokenLinkColorMode } from '$lib/serialization/schema.js'; import { linkEndpoints, linkPathD } from '$lib/domain/link-geometry.js'; import { escapeXml } from './xml.js'; const ATTRIBUTION_FOOTER_PX = 28; const ATTRIBUTION_FONT = '"Google Sans", sans-serif'; +function parseHexRgb(hex: string): [number, number, number] | null { + let h = hex.replace(/^#/u, '').replace(/[^0-9a-fA-F]/gu, ''); + if (h.length === 3) { + h = h + .split('') + .map((c) => c + c) + .join(''); + } + if (!/^[0-9a-fA-F]{6}$/u.test(h)) return null; + return [parseInt(h.slice(0, 2), 16), parseInt(h.slice(2, 4), 16), parseInt(h.slice(4, 6), 16)]; +} + +function byteHex(n: number): string { + const x = Math.max(0, Math.min(255, Math.round(n))); + return x.toString(16).padStart(2, '0'); +} + +/** Linear blend toward canvas background (aligned with preview token background tint). */ +function mixLinkBackground(linkHex: string, canvasHex: string, linkWeight = 0.28): string { + const a = parseHexRgb(linkHex); + const b = parseHexRgb(canvasHex); + if (!a || !b) return linkHex; + const t = Math.max(0, Math.min(1, linkWeight)); + const r = a[0] * t + b[0] * (1 - t); + const g = a[1] * t + b[1] * (1 - t); + const bl = a[2] * t + b[2] * (1 - t); + return `#${byteHex(r)}${byteHex(g)}${byteHex(bl)}`; +} + +function shouldRenderConnectionPath( + conn: Connection, + lineOrder: string[], + pairControls: PairControlV2[] +): boolean { + const pair = canonicalPair( + lineOrder, + tokenLineId(conn.upperTokenId), + tokenLineId(conn.lowerTokenId) + ); + if (!pair) return false; + return showConnectorsForPair(pairControls, pair.upperLineId, pair.lowerLineId); +} + export function buildStandaloneSvgString(args: { width: number; height: number; /** Matches on-screen preview / raster exports (PNG, PDF). */ backgroundColor: string; - fontFamilySource: string; - fontFamilyTarget: string; - /** Interlinear gloss lines */ - fontFamilyGloss: string; - fontSizeSource: number; - fontSizeTarget: number; - glossFontSize: number; defaultTextColor: string; colorTokensByLink: boolean; + tokenLinkColorMode?: TokenLinkColorMode; lineStyle: 'straight' | 'curved'; lineThickness: number; lineOpacity: number; - sourceTokens: Token[]; - targetTokens: Token[]; + /** In display order (top to bottom). */ + lineOrder: string[]; + lines: { lineId: string; tokens: Token[]; fontFamilyStack: string; textSizePx: number }[]; tokenLayout: Record; - links: Link[]; - showGloss: boolean; + connections: Connection[]; + pairControls: PairControlV2[]; /** When true (default), reserve a footer band with static attribution (PNG/PDF/SVG file). Omit for HTML wrapper (clickable line below SVG). */ includeAttributionFooter?: boolean; /** Google Fonts stylesheet URLs (@import in SVG) so standalone files resolve the same faces as the preview. */ @@ -44,22 +84,17 @@ export function buildStandaloneSvgString(args: { width, height, backgroundColor, - fontFamilySource, - fontFamilyTarget, - fontFamilyGloss, - fontSizeSource, - fontSizeTarget, - glossFontSize, defaultTextColor, colorTokensByLink, + tokenLinkColorMode = 'text', lineStyle, lineThickness, lineOpacity, - sourceTokens, - targetTokens, + lineOrder, + lines, tokenLayout, - links, - showGloss, + connections, + pairControls, includeAttributionFooter = true, embedFontCdataImports, embedFontCss, @@ -82,10 +117,11 @@ export function buildStandaloneSvgString(args: { : ''; const paths: string[] = []; - for (const link of links) { - const color = link.color ?? '#94a3b8'; - const p1 = tokenLayout[link.sourceId]; - const p2 = tokenLayout[link.targetId]; + for (const conn of connections) { + if (!shouldRenderConnectionPath(conn, lineOrder, pairControls)) continue; + const color = conn.color ?? '#94a3b8'; + const p1 = tokenLayout[conn.upperTokenId]; + const p2 = tokenLayout[conn.lowerTokenId]; if (!p1 || !p2) continue; const { x1, y1, x2, y2 } = linkEndpoints(p1, p2); const d = linkPathD(x1, y1, x2, y2, lineStyle); @@ -94,54 +130,49 @@ export function buildStandaloneSvgString(args: { ); } + const tokenRects: string[] = []; const texts: string[] = []; function tokenFill(tokenId: string): string { if (!colorTokensByLink) return defaultTextColor; - const link = primaryLinkForToken(links, tokenId); + const link = primaryConnectionForToken(connections, tokenId); if (!link?.color) return defaultTextColor; + if (tokenLinkColorMode === 'background') return defaultTextColor; return link.color; } + function tokenBgHex(tokenId: string): string | null { + if (!colorTokensByLink || tokenLinkColorMode !== 'background') return null; + const link = primaryConnectionForToken(connections, tokenId); + if (!link?.color) return null; + return mixLinkBackground(link.color, backgroundColor, 0.28); + } + function pushTokenText(t: Token, fontFamily: string, sizePx: number) { const box = tokenLayout[t.id]; if (!box) return; const fill = tokenFill(t.id); + const bg = tokenBgHex(t.id); + if (bg) { + tokenRects.push( + `` + ); + } texts.push( `${escapeXml(t.text)}` ); } - for (const t of sourceTokens) pushTokenText(t, fontFamilySource, fontSizeSource); - for (const t of targetTokens) pushTokenText(t, fontFamilyTarget, fontSizeTarget); - - if (showGloss) { - for (const t of sourceTokens) { - const gid = `gloss-${t.id}`; - const box = tokenLayout[gid]; - const g = t.gloss?.trim(); - if (!box || !g) continue; - const fill = tokenFill(t.id); - texts.push( - `${escapeXml(g)}` - ); - } - for (const t of targetTokens) { - const gid = `gloss-${t.id}`; - const box = tokenLayout[gid]; - const g = t.gloss?.trim(); - if (!box || !g) continue; - const fill = tokenFill(t.id); - texts.push( - `${escapeXml(g)}` - ); + for (const row of lines) { + for (const t of row.tokens) { + pushTokenText(t, row.fontFamilyStack, row.textSizePx); } } const bgRect = ``; const attribution = includeAttributionFooter - ? `${escapeXml(`Created with ${ALIGNER_SITE_HOST}`)}` + ? `${escapeXml(EXPORT_ATTRIBUTION_PLAIN)}` : ''; /** Inset from the full export rectangle (including footer band) — same on right and bottom. */ @@ -158,5 +189,5 @@ export function buildStandaloneSvgString(args: { ` : ''; - return `\n${fontDefs}${bgRect}${paths.join('')}${texts.join('')}${cornerQr}${attribution}`; + return `\n${fontDefs}${bgRect}${tokenRects.join('')}${paths.join('')}${texts.join('')}${cornerQr}${attribution}`; } diff --git a/bitext/src/lib/fonts/custom-fonts.ts b/bitext/src/lib/fonts/custom-fonts.ts index 5cec9fa..15ec9f3 100644 --- a/bitext/src/lib/fonts/custom-fonts.ts +++ b/bitext/src/lib/fonts/custom-fonts.ts @@ -1,4 +1,4 @@ -import { get, set, del } from 'idb-keyval'; +import { get, set, del, keys } from 'idb-keyval'; const KEY_PREFIX = 'bitext-font:'; @@ -17,3 +17,12 @@ export async function loadCustomFontBlob(name: string): Promise { await del(fontStorageKey(name)); } + +/** All stored custom font family names (from this app’s IDB prefix). */ +export async function listStoredCustomFontNames(): Promise { + const all = await keys(); + return all + .filter((k): k is string => typeof k === 'string' && k.startsWith(KEY_PREFIX)) + .map((k) => k.slice(KEY_PREFIX.length)) + .sort((a, b) => a.localeCompare(b)); +} diff --git a/bitext/src/lib/fonts/ensure-document-fonts.ts b/bitext/src/lib/fonts/ensure-document-fonts.ts index 676bbc4..983c76a 100644 --- a/bitext/src/lib/fonts/ensure-document-fonts.ts +++ b/bitext/src/lib/fonts/ensure-document-fonts.ts @@ -1,27 +1,16 @@ import { loadCustomFontBlob } from './custom-fonts.js'; -import type { VisualSettingsV1 } from '$lib/serialization/schema.js'; +import type { LineV2 } from '$lib/serialization/schema.js'; const loadedKeys = new Set(); -/** - * Custom fonts in Bitext are stored in IDB; SVG rasterization (PNG/PDF) often does not - * apply @font-face from `