+
+
+ {#if s.autoFit}
+
+
+
+ settingsStore.patch({
+ autoFitVariance: Number((e.currentTarget as HTMLInputElement).value)
+ })}
+ />
+
+ 0% = every line uses one size · 100% = each line is sized on its own
+
+
+ {/if}
+
l.id);
const styledLines = exportStyledLines();
+ // Auto-fit scales the DOM font size; the export draws glyphs at that same scaled size so
+ // they match the measured token boxes (positions already reflect the scaled DOM).
const lines = styledLines.map((l) => ({
lineId: l.id,
tokens: projectStore.tokensOnLine(l.id),
fontFamilyStack: svgFontFamilyStackLine(l),
- textSizePx: l.textSizePx
+ textSizePx: l.textSizePx * (lay.fontScaleByLine[l.id] ?? 1)
}));
return buildStandaloneSvgString({
width: Math.max(1, lay.width),
@@ -87,6 +89,7 @@
lineStyle: s.lineStyle,
lineThickness: s.lineThickness,
lineOpacity: s.lineOpacity,
+ contentScale: lay.contentScale,
lineOrder,
lines,
tokenLayout: lay.tokenLayout,
diff --git a/bitext/src/lib/domain/autofit.test.ts b/bitext/src/lib/domain/autofit.test.ts
new file mode 100644
index 0000000..095dccd
--- /dev/null
+++ b/bitext/src/lib/domain/autofit.test.ts
@@ -0,0 +1,96 @@
+import { describe, expect, it } from 'vitest';
+import { chromeScale, computeAutoFitScales, scalesChanged } from './autofit.js';
+
+describe('computeAutoFitScales', () => {
+ it('leaves lines that fit at scale 1', () => {
+ const out = computeAutoFitScales(
+ [
+ { lineId: 'a', width: 100, effScale: 1 },
+ { lineId: 'b', width: 200, effScale: 1 }
+ ],
+ 300,
+ 1
+ );
+ expect(out.a).toBe(1);
+ expect(out.b).toBe(1);
+ });
+
+ it('per-line (variance 1): each line shrinks only as much as it needs', () => {
+ const out = computeAutoFitScales(
+ [
+ { lineId: 'short', width: 200, effScale: 1 },
+ { lineId: 'long', width: 400, effScale: 1 }
+ ],
+ 200,
+ 1
+ );
+ expect(out.short).toBeCloseTo(1, 5); // 200 fits
+ expect(out.long).toBeCloseTo(0.5, 5); // 400 → 0.5 to fit 200
+ });
+
+ it('global (variance 0): all lines use the smallest scale', () => {
+ const out = computeAutoFitScales(
+ [
+ { lineId: 'short', width: 200, effScale: 1 },
+ { lineId: 'long', width: 400, effScale: 1 }
+ ],
+ 200,
+ 0
+ );
+ expect(out.short).toBeCloseTo(0.5, 5);
+ expect(out.long).toBeCloseTo(0.5, 5);
+ });
+
+ it('variance interpolates between global and per-line', () => {
+ const out = computeAutoFitScales(
+ [
+ { lineId: 'short', width: 200, effScale: 1 },
+ { lineId: 'long', width: 400, effScale: 1 }
+ ],
+ 200,
+ 0.5
+ );
+ // short: between 0.5 (global) and 1 (own) → 0.75
+ expect(out.short).toBeCloseTo(0.75, 5);
+ expect(out.long).toBeCloseTo(0.5, 5);
+ });
+
+ it('honors the minimum floor and never exceeds 1', () => {
+ const out = computeAutoFitScales([{ lineId: 'x', width: 10000, effScale: 1 }], 100, 1, 0.1);
+ expect(out.x).toBe(0.1);
+ const grow = computeAutoFitScales([{ lineId: 'y', width: 50, effScale: 0.5 }], 400, 1);
+ expect(grow.y).toBe(1); // would be 4 → capped at 1
+ });
+
+ it('converges: applying the scale and re-measuring is stable', () => {
+ // width scales with effScale. Start at 1, one tick, then re-measure.
+ const avail = 200;
+ const naturalAt1 = 400;
+ let eff = 1;
+ for (let i = 0; i < 4; i++) {
+ const width = naturalAt1 * eff;
+ eff = computeAutoFitScales([{ lineId: 'a', width, effScale: eff }], avail, 1).a;
+ }
+ expect(eff).toBeCloseTo(0.5, 3);
+ });
+});
+
+describe('chromeScale', () => {
+ it('is 1 when text is full size and shrinks partially otherwise', () => {
+ expect(chromeScale(1, 0.4)).toBe(1);
+ // content at 0.5 → 40% of the way down: 1 - 0.4*0.5 = 0.8
+ expect(chromeScale(0.5, 0.4)).toBeCloseTo(0.8, 5);
+ // stronger strength shrinks more
+ expect(chromeScale(0.5, 0.65)).toBeCloseTo(0.675, 5);
+ expect(chromeScale(0, 1)).toBe(0);
+ });
+});
+
+describe('scalesChanged', () => {
+ it('detects meaningful changes and ignores tiny ones', () => {
+ expect(scalesChanged({ a: 1 }, { a: 0.5 })).toBe(true);
+ expect(scalesChanged({ a: 0.5 }, { a: 0.5001 })).toBe(false);
+ expect(scalesChanged({}, { a: 1 })).toBe(false);
+ expect(scalesChanged({ a: 1 }, { a: 0.9 })).toBe(true);
+ });
+});
diff --git a/bitext/src/lib/domain/autofit.ts b/bitext/src/lib/domain/autofit.ts
new file mode 100644
index 0000000..31785e5
--- /dev/null
+++ b/bitext/src/lib/domain/autofit.ts
@@ -0,0 +1,77 @@
+/**
+ * Auto-fit shrinks each line's text just enough that it fits its container on a single row.
+ *
+ * It is display-only: the user's `textSizePx` is the maximum; the returned scales are in
+ * `[min, 1]`. `variance` controls how much line scales may differ:
+ * - `0` → every line uses the single smallest scale (uniform / "global").
+ * - `1` → every line uses its own fit scale (independent / "per-line").
+ *
+ * `width` is a line's measured content width at its current `effScale`; `avail` is the width to fit
+ * into. The width∝scale assumption is only approximate (fixed token padding), so the caller applies
+ * the result and re-measures — this function converges in a couple of ticks.
+ */
+
+export interface AutoFitRow {
+ lineId: string;
+ /** Measured content width of the row at its current applied scale. */
+ width: number;
+ /** Scale currently applied to the row (1 on first pass). */
+ effScale: number;
+}
+
+function clamp(n: number, lo: number, hi: number): number {
+ return Math.max(lo, Math.min(hi, n));
+}
+
+export function computeAutoFitScales(
+ rows: AutoFitRow[],
+ avail: number,
+ variance: number,
+ min = 0.1
+): Record {
+ const v = clamp(variance, 0, 1);
+ // Per-line fit target: grow toward 1 when there's slack, shrink when overflowing.
+ const perLine = new Map();
+ for (const r of rows) {
+ if (r.width <= 0 || avail <= 0) {
+ perLine.set(r.lineId, r.effScale);
+ continue;
+ }
+ perLine.set(r.lineId, clamp((r.effScale * avail) / r.width, min, 1));
+ }
+ const values = [...perLine.values()];
+ const smallest = values.length ? Math.min(...values) : 1;
+
+ const out: Record = {};
+ for (const r of rows) {
+ const si = perLine.get(r.lineId) ?? 1;
+ out[r.lineId] = smallest + v * (si - smallest);
+ }
+ return out;
+}
+
+/**
+ * When auto-fit shrinks the text, connectors and the attribution credit should shrink too — but
+ * only partially, so they don't vanish. `strength` is how much of the shrink to apply (0 = none,
+ * 1 = full). `contentScale` is the overall text scale (e.g. the mean of the per-line scales).
+ */
+export const AUTOFIT_LINE_STRENGTH = 0.5;
+export const AUTOFIT_CREDIT_STRENGTH = 0.65;
+
+export function chromeScale(contentScale: number, strength: number): number {
+ const s = Math.max(0, Math.min(1, contentScale));
+ return 1 - strength * (1 - s);
+}
+
+/** True when any line's scale changed by more than `eps` (used to stop the reactive re-measure loop). */
+export function scalesChanged(
+ prev: Record,
+ next: Record,
+ eps = 0.005
+): boolean {
+ const keys = new Set([...Object.keys(prev), ...Object.keys(next)]);
+ for (const k of keys) {
+ if (Math.abs((prev[k] ?? 1) - (next[k] ?? 1)) > eps) return true;
+ }
+ return false;
+}
diff --git a/bitext/src/lib/domain/zoom.test.ts b/bitext/src/lib/domain/zoom.test.ts
new file mode 100644
index 0000000..2d0958b
--- /dev/null
+++ b/bitext/src/lib/domain/zoom.test.ts
@@ -0,0 +1,54 @@
+import { describe, expect, it } from 'vitest';
+import { clampPan, clampZoom, IDENTITY_ZOOM, isIdentity, zoomAt } from './zoom.js';
+
+describe('clampZoom', () => {
+ it('clamps to [1, 6]', () => {
+ expect(clampZoom(0.5)).toBe(1);
+ expect(clampZoom(3)).toBe(3);
+ expect(clampZoom(99)).toBe(6);
+ });
+});
+
+describe('zoomAt', () => {
+ it('keeps the focal point fixed on screen', () => {
+ // From identity, zoom 2x around focal (100, 50).
+ const s = zoomAt(IDENTITY_ZOOM, 2, { x: 100, y: 50 });
+ expect(s.z).toBe(2);
+ // The content point that was under (100,50) must still map to (100,50):
+ // screen = translate + z * content; content = (focal - t0)/z0 = (100,50)
+ const screenX = s.x + s.z * 100;
+ const screenY = s.y + s.z * 50;
+ expect(screenX).toBeCloseTo(100, 6);
+ expect(screenY).toBeCloseTo(50, 6);
+ });
+
+ it('is reversible back to identity', () => {
+ const a = zoomAt(IDENTITY_ZOOM, 3, { x: 40, y: 20 });
+ const b = zoomAt(a, 1, { x: 40, y: 20 });
+ expect(b.z).toBe(1);
+ expect(b.x).toBeCloseTo(0, 6);
+ expect(b.y).toBeCloseTo(0, 6);
+ });
+});
+
+describe('clampPan', () => {
+ it('pins to 0 at zoom 1', () => {
+ const s = clampPan({ z: 1, x: -50, y: 30 }, 300, 200);
+ expect(s.x).toBe(0);
+ expect(s.y).toBe(0);
+ });
+
+ it('keeps zoomed content covering the viewport', () => {
+ // z=2, viewport 300×200 → x in [-300, 0], y in [-200, 0]
+ expect(clampPan({ z: 2, x: 100, y: 100 }, 300, 200).x).toBe(0);
+ expect(clampPan({ z: 2, x: -999, y: -999 }, 300, 200)).toEqual({ z: 2, x: -300, y: -200 });
+ expect(clampPan({ z: 2, x: -150, y: -80 }, 300, 200)).toEqual({ z: 2, x: -150, y: -80 });
+ });
+});
+
+describe('isIdentity', () => {
+ it('detects the untransformed state', () => {
+ expect(isIdentity(IDENTITY_ZOOM)).toBe(true);
+ expect(isIdentity({ z: 1.2, x: 0, y: 0 })).toBe(false);
+ });
+});
diff --git a/bitext/src/lib/domain/zoom.ts b/bitext/src/lib/domain/zoom.ts
new file mode 100644
index 0000000..f2df762
--- /dev/null
+++ b/bitext/src/lib/domain/zoom.ts
@@ -0,0 +1,57 @@
+/**
+ * Pan/zoom math for the preview's interaction-only magnifier.
+ *
+ * Transform model: `translate(x, y) scale(z)` with `transform-origin: 0 0`. Coordinates are in the
+ * wrapper's untransformed local space (screen coord minus the wrapper's untransformed origin).
+ * This is purely a viewing transform — it never changes layout, so measured token positions (and
+ * therefore the export) are unaffected.
+ */
+
+export const MIN_ZOOM = 1;
+export const MAX_ZOOM = 6;
+
+export interface ZoomState {
+ z: number;
+ x: number;
+ y: number;
+}
+
+export const IDENTITY_ZOOM: ZoomState = { z: 1, x: 0, y: 0 };
+
+export function clampZoom(z: number): number {
+ return Math.max(MIN_ZOOM, Math.min(MAX_ZOOM, z));
+}
+
+/**
+ * Zoom to `newZ` while keeping the content point under `focal` fixed on screen.
+ * `focal` is in the wrapper's untransformed local space.
+ */
+export function zoomAt(state: ZoomState, newZ: number, focal: { x: number; y: number }): ZoomState {
+ const z = clampZoom(newZ);
+ const r = z / state.z;
+ return {
+ z,
+ x: focal.x * (1 - r) + state.x * r,
+ y: focal.y * (1 - r) + state.y * r
+ };
+}
+
+/** Keep the (transformed) content covering the `w × h` viewport so it can't be dragged off-screen. */
+export function clampPan(state: ZoomState, w: number, h: number): ZoomState {
+ const minX = w * (1 - state.z);
+ const minY = h * (1 - state.z);
+ return {
+ z: state.z,
+ x: Math.max(minX, Math.min(0, state.x)),
+ y: Math.max(minY, Math.min(0, state.y))
+ };
+}
+
+/** Convert a wheel deltaY into a multiplicative zoom factor (trackpad/ctrl-wheel zoom). */
+export function wheelZoomFactor(deltaY: number): number {
+ return Math.exp(-deltaY * 0.0015);
+}
+
+export function isIdentity(state: ZoomState): boolean {
+ return state.z === 1 && state.x === 0 && state.y === 0;
+}
diff --git a/bitext/src/lib/export/svg.ts b/bitext/src/lib/export/svg.ts
index 25ba4ef..e5bf1dc 100644
--- a/bitext/src/lib/export/svg.ts
+++ b/bitext/src/lib/export/svg.ts
@@ -15,6 +15,11 @@ import {
styleExportFrame,
type StyleId
} from '$lib/domain/styles.js';
+import {
+ chromeScale,
+ AUTOFIT_LINE_STRENGTH,
+ AUTOFIT_CREDIT_STRENGTH
+} from '$lib/domain/autofit.js';
import { escapeXml } from './xml.js';
const ATTRIBUTION_FONT = '"Google Sans", sans-serif';
@@ -75,6 +80,8 @@ export function buildStandaloneSvgString(args: {
lineStyle: 'straight' | 'curved';
lineThickness: number;
lineOpacity: number;
+ /** Overall auto-fit text scale (mean of per-line scales); gently shrinks lines + credit. */
+ contentScale?: number;
/** In display order (top to bottom). */
lineOrder: string[];
lines: { lineId: string; tokens: Token[]; fontFamilyStack: string; textSizePx: number }[];
@@ -101,6 +108,7 @@ export function buildStandaloneSvgString(args: {
lineStyle,
lineThickness,
lineOpacity,
+ contentScale = 1,
lineOrder,
lines,
tokenLayout,
@@ -178,7 +186,9 @@ export function buildStandaloneSvgString(args: {
? ``
: '';
// Soft glow via Gaussian blur (matches the preview's drop-shadow, not a hard outline).
- const effWidth = lineThickness * (conn.widthScale ?? 1);
+ const lineScale = chromeScale(contentScale, AUTOFIT_LINE_STRENGTH);
+ const creditScale = chromeScale(contentScale, AUTOFIT_CREDIT_STRENGTH);
+ const effWidth = lineThickness * (conn.widthScale ?? 1) * lineScale;
const glowDefs: string[] = [];
if (conn.glow) {
const sd = Math.max(2, Math.round(effWidth * 1.1));
@@ -211,7 +221,7 @@ export function buildStandaloneSvgString(args: {
x2,
y2,
lineStyle,
- lineThickness * (conn.ribbonScale ?? 8),
+ lineThickness * (conn.ribbonScale ?? 8) * lineScale,
conn.taper ?? false
);
paths.push(
@@ -233,9 +243,10 @@ export function buildStandaloneSvgString(args: {
const dot = conn.endpointDots;
const fill = escapeXml(dot.color ?? color);
const ring = dot.ring ? ` stroke="${escapeXml(dot.ring)}" stroke-width="1.5"` : '';
+ const dr = Math.round(dot.r * lineScale * 100) / 100;
paths.push(
- ``,
- ``
+ ``,
+ ``
);
}
}
@@ -333,7 +344,7 @@ export function buildStandaloneSvgString(args: {
? ' font-style="italic"'
: '';
const attribution = includeAttributionFooter
- ? `${escapeXml(creditText)}`
+ ? `${escapeXml(creditText)}`
: '';
/** Inset from the full export rectangle (including footer band) — same on right and bottom. */
diff --git a/bitext/src/lib/serialization/compact-v3.ts b/bitext/src/lib/serialization/compact-v3.ts
index 3f0d4d0..3e549fb 100644
--- a/bitext/src/lib/serialization/compact-v3.ts
+++ b/bitext/src/lib/serialization/compact-v3.ts
@@ -65,6 +65,8 @@ function settingsToCompact(rounded: VisualSettingsV2): CompactSettings3 | undefi
}
if (rounded.tokenPunctuationChars) o.px = rounded.tokenPunctuationChars;
if (rounded.style !== def.style) o.st = rounded.style;
+ if (rounded.autoFit !== def.autoFit) o.af = rounded.autoFit ? 1 : 0;
+ if (rounded.autoFitVariance !== def.autoFitVariance) o.av = rounded.autoFitVariance;
if (rounded.background !== def.background) {
o.bg = rounded.background === 'dark' ? 1 : 0;
}
@@ -95,6 +97,8 @@ function compactToVisualSettings(s: CompactSettings3 | undefined): VisualSetting
raw.background = n === 1 ? 'dark' : 'light';
}
if (s.st !== undefined) raw.style = String(s.st);
+ if (s.af !== undefined) raw.autoFit = Number(s.af) === 1;
+ if (s.av !== undefined) raw.autoFitVariance = Number(s.av);
return normalizeVisualSettingsV2(raw);
}
diff --git a/bitext/src/lib/serialization/schema.ts b/bitext/src/lib/serialization/schema.ts
index 88115e1..6313ff5 100644
--- a/bitext/src/lib/serialization/schema.ts
+++ b/bitext/src/lib/serialization/schema.ts
@@ -159,6 +159,10 @@ export interface VisualSettingsV2 {
background: BackgroundMode;
/** Visual style preset (background, frame, connector treatment, default font). */
style: StyleId;
+ /** Shrink text to fit so a line never wraps to a second display row. */
+ autoFit: boolean;
+ /** 0 = all lines share one scale (uniform); 1 = each line fits independently. */
+ autoFitVariance: number;
}
export interface AppStateV2 {
@@ -235,7 +239,9 @@ export function defaultVisualSettingsV2(): VisualSettingsV2 {
tokenPunctuationChars: '',
previewHideChrome: false,
background: 'light',
- style: 'classic'
+ style: 'classic',
+ autoFit: true,
+ autoFitVariance: 0.5
};
}
@@ -311,7 +317,9 @@ export function visualSettingsV1ToV2(v1: VisualSettingsV1): VisualSettingsV2 {
tokenPunctuationChars: '',
previewHideChrome: false,
background: normalizePreviewBackground(v1.background),
- style: 'classic'
+ style: 'classic',
+ autoFit: true,
+ autoFitVariance: 0.5
};
}
@@ -778,6 +786,11 @@ export function normalizeVisualSettingsV2(
previewHideChrome:
typeof raw.previewHideChrome === 'boolean' ? raw.previewHideChrome : d.previewHideChrome,
background: normalizePreviewBackground(raw.background ?? d.background),
- style: isStyleId(raw.style) ? raw.style : d.style
+ style: isStyleId(raw.style) ? raw.style : d.style,
+ autoFit: typeof raw.autoFit === 'boolean' ? raw.autoFit : d.autoFit,
+ autoFitVariance:
+ typeof raw.autoFitVariance === 'number' && Number.isFinite(raw.autoFitVariance)
+ ? Math.max(0, Math.min(1, raw.autoFitVariance))
+ : d.autoFitVariance
};
}
diff --git a/bitext/src/lib/serialization/serialization-roundtrip.test.ts b/bitext/src/lib/serialization/serialization-roundtrip.test.ts
index ee45808..ffab251 100644
--- a/bitext/src/lib/serialization/serialization-roundtrip.test.ts
+++ b/bitext/src/lib/serialization/serialization-roundtrip.test.ts
@@ -95,6 +95,23 @@ describe('compact v3 encode/decode (current share format)', () => {
expect(decodeState(encodeState(classic)).settings.style).toBe('classic');
});
+ it('round-trip: auto-fit off + variance; defaults stay out of the URL', () => {
+ const base = migrate({});
+ expect(base.settings.autoFit).toBe(true);
+ expect(base.settings.autoFitVariance).toBe(0.5);
+ // Defaults (on, 0.5) must not enlarge the payload.
+ expect(encodeState(base)).toBe(
+ encodeState({ ...base, settings: { ...base.settings, autoFit: true, autoFitVariance: 0.5 } })
+ );
+ const s: AppStateV2 = {
+ ...base,
+ settings: { ...base.settings, autoFit: false, autoFitVariance: 0.3 }
+ };
+ const out = decodeState(encodeState(s)).settings;
+ expect(out.autoFit).toBe(false);
+ expect(out.autoFitVariance).toBeCloseTo(0.3, 5);
+ });
+
it('round-trip: three lines, connections, hidden pair control', () => {
const base = migrate({});
const lines = [
diff --git a/bitext/src/lib/state/layoutExport.svelte.ts b/bitext/src/lib/state/layoutExport.svelte.ts
index 9646571..71c03e4 100644
--- a/bitext/src/lib/state/layoutExport.svelte.ts
+++ b/bitext/src/lib/state/layoutExport.svelte.ts
@@ -15,9 +15,21 @@ class LayoutExportStore {
linkPaths = $state([]);
/** Vertical center Y of each line’s token row, keyed by line id */
lineRowY = $state>({});
+ /** Auto-fit scale applied to each line's font size (keyed by line id; missing ⇒ 1). */
+ fontScaleByLine = $state>({});
+ /** Overall text scale (mean of per-line auto-fit scales); used to shrink lines/credit in export. */
+ contentScale = $state(1);
/** Bumped so preview layout is remeasured (e.g. before export) with up-to-date font metrics. */
layoutRemeasureTick = $state(0);
+ setFontScaleByLine(map: Record) {
+ this.fontScaleByLine = map;
+ }
+
+ setContentScale(scale: number) {
+ this.contentScale = scale;
+ }
+
requestRemeasure() {
this.layoutRemeasureTick++;
}
diff --git a/bitext/src/routes/+page.svelte b/bitext/src/routes/+page.svelte
index 60d44c5..ca21147 100644
--- a/bitext/src/routes/+page.svelte
+++ b/bitext/src/routes/+page.svelte
@@ -397,10 +397,15 @@