@hypit/hypit 0.1.14 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (82) hide show
  1. package/README.md +2 -2
  2. package/dist/public/browser-capture.d.ts +9 -3
  3. package/dist/public/caption.d.ts +2 -0
  4. package/dist/public/narrative.d.ts +2 -0
  5. package/dist/public/runtime-kit.d.ts +2 -2
  6. package/dist/public/speech.d.ts +2 -0
  7. package/examples/semantic-composition/packages/responsive-explainer/README.md +1 -1
  8. package/package.json +1 -1
  9. package/packages/browser-capture/README.md +9 -2
  10. package/packages/browser-capture/package.json +5 -0
  11. package/packages/browser-capture/src/browser.ts +22 -8
  12. package/packages/browser-capture/src/index.ts +8 -2
  13. package/packages/caption/README.md +1 -1
  14. package/packages/caption/src/display.ts +1 -0
  15. package/packages/caption-fine/README.md +19 -5
  16. package/packages/caption-fine/package.json +1 -0
  17. package/packages/caption-fine/src/joined-box.ts +114 -0
  18. package/packages/caption-fine/src/manifest.ts +1 -1
  19. package/packages/caption-fine/src/render.ts +99 -92
  20. package/packages/caption-fine/src/spacing.ts +8 -61
  21. package/packages/caption-fine-studio/src/index.ts +2 -4
  22. package/packages/cli/README.md +3 -2
  23. package/packages/cli/src/arguments.ts +2 -2
  24. package/packages/cli/src/command.ts +1 -1
  25. package/packages/cli/src/commands/environment.ts +9 -2
  26. package/packages/cli/src/machine-view.ts +1 -1
  27. package/packages/cli/src/output.ts +1 -0
  28. package/packages/credential-store-os/src/store.ts +5 -1
  29. package/packages/narrative/README.md +3 -1
  30. package/packages/narrative/src/schema.ts +1 -0
  31. package/packages/narrative/src/types.ts +2 -0
  32. package/packages/provider-whisperx-local/README.md +26 -10
  33. package/packages/provider-whisperx-local/src/activation.ts +10 -1
  34. package/packages/provider-whisperx-local/src/program.ts +8 -4
  35. package/packages/runtime-host-node/src/index.ts +1 -0
  36. package/packages/runtime-kit/README.md +6 -2
  37. package/packages/runtime-kit/src/index.ts +2 -2
  38. package/packages/runtime-local/README.md +6 -1
  39. package/packages/runtime-local/src/host.ts +2 -0
  40. package/packages/runtime-local/src/index.ts +1 -0
  41. package/packages/runtime-local/src/programs.ts +53 -17
  42. package/packages/script/README.md +81 -34
  43. package/packages/script/bin/migrate-0.2.mjs +90 -0
  44. package/packages/script/src/edit.ts +28 -60
  45. package/packages/script/src/lexical.ts +59 -130
  46. package/packages/script/src/manifest.ts +11 -11
  47. package/packages/script/src/narrative.ts +4 -2
  48. package/packages/script/src/parser.ts +140 -193
  49. package/packages/script/src/types.ts +2 -3
  50. package/packages/studio/src/ui/code.ts +1 -1
  51. package/packages/studio/src/ui/main.ts +2 -2
  52. package/packages/studio/src/ui/markers.ts +3 -3
  53. package/packages/studio/src/ui/selection.ts +1 -1
  54. package/packages/studio/src/ui/syntax.ts +42 -21
  55. package/packages/temporal-markup/README.md +1 -1
  56. package/packages/video-cli/README.md +9 -5
  57. package/packages/video-cli/src/capture.ts +17 -2
  58. package/packages/video-cli/src/creation.ts +4 -6
  59. package/packages/video-cli/src/media.ts +10 -3
  60. package/packages/whisperx/README.md +56 -3
  61. package/packages/whisperx/src/component.ts +2 -5
  62. package/packages/whisperx/src/evidence.ts +4 -3
  63. package/packages/whisperx/src/index.ts +1 -0
  64. package/packages/whisperx/src/manifest.ts +2 -3
  65. package/packages/whisperx/src/surface.ts +3 -6
  66. package/packages/whisperx/src/types.ts +9 -1
  67. package/packages/yt-dlp/README.md +9 -3
  68. package/packages/yt-dlp/package.json +2 -1
  69. package/packages/yt-dlp/src/download.ts +10 -24
  70. package/packages/yt-dlp/src/environment.ts +36 -0
  71. package/packages/yt-dlp/src/index.ts +1 -0
  72. package/services/whisperx/README.md +45 -12
  73. package/services/whisperx/src/hypit_whisperx_service/application.py +3 -0
  74. package/services/whisperx/src/hypit_whisperx_service/check.py +7 -0
  75. package/services/whisperx/src/hypit_whisperx_service/config.py +7 -0
  76. package/services/whisperx/src/hypit_whisperx_service/engine.py +21 -5
  77. package/services/whisperx/src/hypit_whisperx_service/models.py +131 -0
  78. package/services/whisperx/src/hypit_whisperx_service/prepare.py +21 -3
  79. package/services/whisperx/src/hypit_whisperx_service/resources.py +19 -3
  80. package/services/yt-dlp/README.md +14 -15
  81. package/services/yt-dlp/pyproject.toml +1 -1
  82. package/services/yt-dlp/uv.lock +513 -2
package/README.md CHANGED
@@ -110,7 +110,7 @@ Drop in a video and your agent clones the whole workflow — or describe what yo
110
110
 
111
111
  ## Stay Tuned with Us
112
112
 
113
- Star us, and you will receive all release notifications from GitHub without any delay!
113
+ Star Hypit to bookmark the project. To receive release notifications, select **Watch Custom → Releases** on GitHub.
114
114
 
115
115
  ![Stay tuned with us](https://storage.googleapis.com/hypit-public-assets/showcase/npm/2026-09-10/star.gif)
116
116
 
@@ -151,7 +151,7 @@ commands and the repository layout.
151
151
  <td width="220" align="center">
152
152
  <a href="https://openagents.org/blog/posts/2026-09-08-pilot-user-program-zh-hypit"><img alt="OpenAgents" src="./docs/public/openagents.png" width="180"></a>
153
153
  </td>
154
- <td>OpenAgents Workspace is a product that helps you manage AI agents, allowing you to invoke agents running on any device (whether on a mobile phone or computer) anytime, anywhere. Agents can collaborate within the platform to help you complete tasks and boost efficiency. It currently supports integration with over 20 types of agents, including Claude, Codex, OpenClaw, and Hermes (Details on supported models: <a href="https://api-gateway.openagents.org/static/index.html">https://api-gateway.openagents.org/static/index.html</a>)</td>
154
+ <td>OpenAgents Workspace is a product that helps you manage AI agents, allowing you to invoke agents running on any device (whether on a mobile phone or computer) anytime, anywhere. Agents can collaborate within the platform to help you complete tasks and boost efficiency. It currently supports integration with over 20 types of agents, including Claude, Codex, OpenClaw, and Hermes (<a href="https://openagents.org">https://openagents.org</a>)</td>
155
155
  </tr>
156
156
  <tr>
157
157
  <td width="220" align="center">
@@ -1,10 +1,16 @@
1
1
  import { LaunchOptions, Browser, Page, ScreenshotOptions, RecordOptions } from 'puppeteer-core';
2
2
 
3
- declare function captureBrowserExecutablePath(): Promise<string>;
3
+ type CaptureBrowserOptions = {
4
+ readonly version?: string;
5
+ readonly cacheDirectory?: string;
6
+ readonly downloadBaseUrl?: string;
7
+ };
8
+ declare function captureBrowserExecutablePath(options?: CaptureBrowserOptions): Promise<string>;
4
9
  /** Explicit setup, using Puppeteer's cache configuration and upstream browser installer. */
5
- declare function installCaptureBrowser(): Promise<string>;
10
+ declare function installCaptureBrowser(options?: CaptureBrowserOptions): Promise<string>;
6
11
 
7
12
  type CaptureOptions = {
13
+ readonly browser?: CaptureBrowserOptions;
8
14
  /** Ordinary Puppeteer launch options, including channel, executablePath and defaultViewport. */
9
15
  readonly launch?: LaunchOptions;
10
16
  /** Page operation and navigation timeout; Puppeteer's default applies when omitted. */
@@ -53,4 +59,4 @@ type CaptureScript = (context: CaptureScriptContext) => Promise<void>;
53
59
  declare function withCapture(options: CaptureOptions, task: (session: CaptureSession) => Promise<void>, onOutput?: (output: CaptureOutput) => void): Promise<readonly CaptureOutput[]>;
54
60
 
55
61
  export { captureBrowserExecutablePath, installCaptureBrowser, withCapture };
56
- export type { CaptureOptions, CaptureOutput, CaptureRecording, CaptureScript, CaptureScriptContext, CaptureSession, RecordingCaptureOptions, ScreenshotCaptureOptions };
62
+ export type { CaptureBrowserOptions, CaptureOptions, CaptureOutput, CaptureRecording, CaptureScript, CaptureScriptContext, CaptureSession, RecordingCaptureOptions, ScreenshotCaptureOptions };
@@ -187,6 +187,8 @@ type NarrativeMoment = {
187
187
  };
188
188
  /** One author-visible word surface. Punctuation owned by the surface is preserved. */
189
189
  type CaptionDisplayWord = {
190
+ /** Authored display separator from the preceding word; ignored at a displayed line start. */
191
+ readonly separatorBefore: "" | " ";
190
192
  readonly id: string;
191
193
  readonly unitId: string;
192
194
  readonly segmentId: string;
@@ -220,6 +220,8 @@ type NarrativeMomentRef = NarrativeMoment & {
220
220
  };
221
221
  /** One author-visible word surface. Punctuation owned by the surface is preserved. */
222
222
  type CaptionDisplayWord = {
223
+ /** Authored display separator from the preceding word; ignored at a displayed line start. */
224
+ readonly separatorBefore: "" | " ";
223
225
  readonly id: string;
224
226
  readonly unitId: string;
225
227
  readonly segmentId: string;
@@ -358,11 +358,11 @@ type ManagedProgramCommand = {
358
358
  readonly env?: Readonly<Record<string, string>>;
359
359
  };
360
360
  type ManagedProgramInstallation = {
361
- /** Truthful inspection of the installed program; no installer-owned receipt. */
361
+ /** Read-only inspection of the selected environment and resources; no downloads or receipts. */
362
362
  probe(): Promise<ManagedProgramState>;
363
363
  /** Reconcile an installed environment before a cold start, using its package manager's cache. */
364
364
  readonly prepareBeforeStart?: boolean;
365
- /** Commands run when installation is missing, or before a cold start when requested above. */
365
+ /** Explicit preparation commands, run for missing resources or cold reconciliation above. */
366
366
  readonly commands: readonly ManagedProgramCommand[];
367
367
  };
368
368
  type ManagedProgramState = {
@@ -291,6 +291,8 @@ type NarrativeMoment = {
291
291
  };
292
292
  /** One author-visible word surface. Punctuation owned by the surface is preserved. */
293
293
  type CaptionDisplayWord = {
294
+ /** Authored display separator from the preceding word; ignored at a displayed line start. */
295
+ readonly separatorBefore: "" | " ";
294
296
  readonly id: string;
295
297
  readonly unitId: string;
296
298
  readonly segmentId: string;
@@ -30,7 +30,7 @@ With prepared Takes, a Canvas and an exact FontStack already available:
30
30
  ```
31
31
 
32
32
  `during` also accepts a Script Selection or Segment; the shared Window syntax supports other authored
33
- intervals. `reveal` selects a Script Moment, such as `@demonstrate!` before the relevant word. Rewriting
33
+ intervals. `reveal` selects a Script Moment, such as `@{demonstrate!}` before the relevant word. Rewriting
34
34
  the Script or using another delivery changes the projected frame while retaining the layout behavior.
35
35
  `transition-frames` is the duration of that change, separate from the scene's lifetime. `stack-order`
36
36
  places this scene among other contributions. The caller supplies its title and exact fonts.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hypit/hypit",
3
- "version": "0.1.14",
3
+ "version": "0.2.0",
4
4
  "homepage": "https://hypit.ai",
5
5
  "repository": {
6
6
  "type": "git",
@@ -11,8 +11,13 @@ needs no browser-library installation or private `node_modules` import path.
11
11
  `hypit capture install-browser` explicitly prepares the package's tested Chrome for Testing revision
12
12
  in the browser cache (`PUPPETEER_CACHE_DIR`, or `~/.cache/puppeteer`).
13
13
  `captureBrowserExecutablePath()` reports that path.
14
- The package pins Chrome 153.0.8010.12 for the native recording API; `installCaptureBrowser()` uses
15
- the upstream installer. Existing compatible browsers remain selectable through launch options.
14
+ The recommended version is declared in this package's `package.json` under `hypit.captureBrowser`.
15
+ `installCaptureBrowser()` uses the upstream installer. Both it and `captureBrowserExecutablePath()`
16
+ accept `{ version, cacheDirectory, downloadBaseUrl }`; capture accepts the same selection as
17
+ `options.browser`. CLI equivalents are `--browser-version`, `--browser-cache` and
18
+ `--browser-download-base-url`. A custom download base must serve the upstream archive layout;
19
+ there is no retry against an unselected source. Use the same version and cache when preparing and
20
+ capturing. Capture never installs a browser, and a missing selected executable is an error. Existing compatible browsers remain selectable through launch options.
16
21
 
17
22
  ## Library
18
23
 
@@ -26,6 +31,8 @@ file as it finishes. A task failure closes the browser while preserving earlier
26
31
  `defaultViewport` replaces it. `channel` selects an installed Chrome channel, `executablePath`
27
32
  selects a specific executable, and the default uses this package's tested Chrome for Testing.
28
33
  `PUPPETEER_CACHE_DIR` selects the cache directory.
34
+ - `browser`: managed browser version/cache selection. It cannot be combined with an explicit
35
+ `launch.channel` or `launch.executablePath`.
29
36
  - `timeoutMs`: operation and navigation timeout for the initial page. Omission keeps Puppeteer's
30
37
  default; `0` disables that timeout. Scripts can configure other pages themselves.
31
38
  - `ffprobePath`: metadata reader for finished recordings; defaults to `ffprobe` on `PATH`.
@@ -11,5 +11,10 @@
11
11
  "@puppeteer/browsers": "3.2.2",
12
12
  "puppeteer-core": "25.10.0",
13
13
  "sharp": "0.35.4"
14
+ },
15
+ "hypit": {
16
+ "captureBrowser": {
17
+ "version": "153.0.8010.12"
18
+ }
14
19
  }
15
20
  }
@@ -1,19 +1,33 @@
1
+ import { readFileSync } from "node:fs";
1
2
  import { homedir } from "node:os";
2
3
  import { join } from "node:path";
3
4
  import { Browser, computeExecutablePath, install } from "@puppeteer/browsers";
4
5
 
5
- // Page.record uses Chrome's native recording protocol, introduced in Chrome 153.
6
- // This package owns its tested browser revision independently of other renderers.
7
- const chromeVersion = "153.0.8010.12";
6
+ export type CaptureBrowserOptions = {
7
+ readonly version?: string;
8
+ readonly cacheDirectory?: string;
9
+ readonly downloadBaseUrl?: string;
10
+ };
8
11
 
9
- const cacheDirectory = () => process.env.PUPPETEER_CACHE_DIR ?? join(homedir(), ".cache", "puppeteer");
12
+ function selection(options: CaptureBrowserOptions) {
13
+ const manifest = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")) as {
14
+ hypit?: { captureBrowser?: { version?: string } };
15
+ };
16
+ const buildId = options.version ?? manifest.hypit?.captureBrowser?.version;
17
+ if (typeof buildId !== "string" || !/^\d+\.\d+\.\d+\.\d+$/u.test(buildId)) throw new Error("Capture browser requires an exact four-part version");
18
+ const cacheDir = options.cacheDirectory ?? process.env.PUPPETEER_CACHE_DIR ?? join(homedir(), ".cache", "puppeteer");
19
+ return { browser: Browser.CHROME, buildId, cacheDir };
20
+ }
10
21
 
11
- export async function captureBrowserExecutablePath(): Promise<string> {
12
- return computeExecutablePath({ browser: Browser.CHROME, buildId: chromeVersion, cacheDir: cacheDirectory() });
22
+ export async function captureBrowserExecutablePath(options: CaptureBrowserOptions = {}): Promise<string> {
23
+ return computeExecutablePath(selection(options));
13
24
  }
14
25
 
15
26
  /** Explicit setup, using Puppeteer's cache configuration and upstream browser installer. */
16
- export async function installCaptureBrowser(): Promise<string> {
17
- const installed = await install({ browser: Browser.CHROME, buildId: chromeVersion, cacheDir: cacheDirectory() });
27
+ export async function installCaptureBrowser(options: CaptureBrowserOptions = {}): Promise<string> {
28
+ if (options.downloadBaseUrl !== undefined && !["https:", "http:"].includes(new URL(options.downloadBaseUrl).protocol)) {
29
+ throw new Error("Capture browser download source must use HTTP or HTTPS");
30
+ }
31
+ const installed = await install({ ...selection(options), ...(options.downloadBaseUrl === undefined ? {} : { baseUrl: options.downloadBaseUrl }) });
18
32
  return installed.executablePath;
19
33
  }
@@ -10,7 +10,10 @@ import sharp from "sharp";
10
10
  import { captureBrowserExecutablePath } from "./browser.js";
11
11
  export { captureBrowserExecutablePath, installCaptureBrowser } from "./browser.js";
12
12
 
13
+ export type { CaptureBrowserOptions } from "./browser.js";
14
+
13
15
  export type CaptureOptions = {
16
+ readonly browser?: import("./browser.js").CaptureBrowserOptions;
14
17
  /** Ordinary Puppeteer launch options, including channel, executablePath and defaultViewport. */
15
18
  readonly launch?: LaunchOptions;
16
19
  /** Page operation and navigation timeout; Puppeteer's default applies when omitted. */
@@ -99,10 +102,13 @@ export async function withCapture(
99
102
  onOutput?: (output: CaptureOutput) => void,
100
103
  ): Promise<readonly CaptureOutput[]> {
101
104
  const launch = { ...options.launch };
105
+ if ((launch.channel || launch.executablePath) && options.browser !== undefined) {
106
+ throw new Error("Choose a managed capture browser or an explicit launch browser, not both");
107
+ }
102
108
  if (!launch.channel && !launch.executablePath) {
103
- launch.executablePath = await captureBrowserExecutablePath();
109
+ launch.executablePath = await captureBrowserExecutablePath(options.browser);
104
110
  if (!existsSync(launch.executablePath)) throw new Error(
105
- `Capture browser is not installed at ${launch.executablePath}. Run hypit capture install-browser, or select an installed compatible browser.`);
111
+ `Capture browser is not installed at ${launch.executablePath}. Run hypit capture install-browser with the same version and cache selection, or select an installed compatible browser.`);
106
112
  }
107
113
  const browser = await puppeteer.launch({
108
114
  defaultViewport: { width: 1280, height: 720, deviceScaleFactor: 1 },
@@ -7,7 +7,7 @@ Caption has two independent structures: Script organizes displayed words into Cu
7
7
  choose how those Cues appear. A Use may begin inside a Cue. It changes presentation without changing
8
8
  that Cue's text or restarting its word timing.
9
9
 
10
- `CaptionDocument` owns displayed words, display/pronunciation associations, word attributes and `||`
10
+ `CaptionDocument` owns displayed words with authored separators, display/pronunciation associations, word attributes and `||`
11
11
  breaks. `Timeline` owns placed Takes and measured word times. `temporalizeCaptionDocument(document,
12
12
  timeline)` joins their identities and returns `TimedCaptionProjection`: complete Cues with original
13
13
  unit times. Segment, speaking-turn and explicit Cue boundaries organize this content. No Style is
@@ -8,6 +8,7 @@ function assert(condition: unknown, message: string): asserts condition {
8
8
  export function assertCaptionDocument(value: CaptionDocument): void {
9
9
  assert(value.id.length > 0, "CaptionDocument identity is invalid");
10
10
  assert(value.units.length > 0 && value.words.length > 0, "CaptionDocument is empty");
11
+ for (const word of value.words) assert(word.separatorBefore === "" || word.separatorBefore === " ", "Caption word must declare its authored separator");
11
12
  const words = new Map(value.words.map((word) => [word.id, word]));
12
13
  assert(words.size === value.words.length, "CaptionDocument word ids are repeated");
13
14
  const units = new Set<string>();
@@ -82,8 +82,8 @@ caption.primary {
82
82
 
83
83
  ## Language, spacing and line layout
84
84
 
85
- The same Caption pipeline serves English and Chinese. Script emits English lexical words and
86
- individual Han characters as Display Words; punctuation stays with its display word. Fine uses
85
+ The same Caption pipeline serves authored text across writing systems. Script emits lexical words
86
+ and individual Han characters as Display Words; punctuation stays with its display word. Fine uses
87
87
  those units for timing and active Paint, while authored Cues remain complete reading phrases.
88
88
  Dual Text retains its complete alignment unit even when it displays or speaks several words.
89
89
 
@@ -94,9 +94,23 @@ explicit authored group, not a renderer-selected segmentation, and does not intr
94
94
  The underlying character anchors remain available to other Timeline consumers. `wipe` and
95
95
  `typewriter` still animate inside the complete unit over its interval when deliberately selected.
96
96
 
97
- `word-gap` applies between Latin words and at Chinese/Latin boundaries. Adjacent Han characters and
98
- full-width punctuation carry no extra word gap. `letter-spacing` controls glyph tracking. Exact
99
- font fallbacks supply the required glyphs; the layout does not select a font by language.
97
+ `word-gap` sizes the separators authored in `CaptionDisplayWord.separatorBefore`. A numeric or
98
+ writing-system boundary does not create a space: `3D` and `3개월` stay joined, while `是的 就是这样`
99
+ keeps its space. Base text, active text, underlines and joined boxes consume the same boundaries.
100
+ For `active-box: trail; active-box-continuity: joined`, Fine measures the complete stationary Cue,
101
+ including the inactive suffix, and joins its painted line rectangles. An over-wide word's internal
102
+ lines are included. Padding and borders extend beyond those rectangles without changing text flow;
103
+ overlapping line backgrounds share one outline and paint translucent color once. Cue motion then
104
+ moves text and decoration together.
105
+
106
+ This decoration uses HyperFrames' existing local browser-program extension, owned by Fine. Its
107
+ measurement copy uses the same exact-font text and available width, is removed synchronously after
108
+ measurement, and is recomputed when a frame is sought (including after fonts finish loading). It
109
+ adds no layout records to Script, Caption, Timeline or Runtime. Other backends must support the
110
+ selected program format explicitly.
111
+
112
+ `letter-spacing` controls glyph tracking. Exact font fallbacks supply the required glyphs; the layout
113
+ does not select a font by language.
100
114
 
101
115
  | Control | Behavior |
102
116
  | --- | --- |
@@ -15,6 +15,7 @@
15
15
  "@hypit/component-kit": "workspace:*",
16
16
  "@hypit/composition": "workspace:*",
17
17
  "@hypit/elaborator": "workspace:*",
18
+ "@hypit/hyperframes": "workspace:*",
18
19
  "@hypit/media": "workspace:*",
19
20
  "@hypit/narrative": "workspace:*",
20
21
  "@hypit/program-space": "workspace:*",
@@ -0,0 +1,114 @@
1
+ /**
2
+ * Fine's joined decoration is measured after browser layout. Its temporary, untransformed copy
3
+ * uses the same exact-font children and content width, so Cue motion cannot distort measurements.
4
+ * Nothing is retained outside the program or written back into Script/Caption/Timeline.
5
+ */
6
+ export const joinedBoxSetup = String.raw`
7
+ const layout = root.querySelector('[data-fine-box-layout]');
8
+ const layers = [...root.querySelectorAll('[data-caption-active-box="joined"]')];
9
+ const ns = 'http://www.w3.org/2000/svg';
10
+ const paths = layers.map(layer => {
11
+ const svg = document.createElementNS(ns, 'svg');
12
+ svg.style.cssText = 'position:absolute;inset:0;width:100%;height:100%;overflow:visible';
13
+ const path = document.createElementNS(ns, 'path');
14
+ path.setAttribute('fill', data.background);
15
+ path.setAttribute('stroke', data.borderColor);
16
+ path.setAttribute('stroke-width', String(data.borderWidth));
17
+ svg.append(path); layer.append(svg); return path;
18
+ });
19
+ // Union on actual rectangle edges. Tracing only the exterior removes internal borders and
20
+ // paints translucent fills once, including where line padding overlaps the neighbouring line.
21
+ const outline = rectangles => {
22
+ const xs = [...new Set(rectangles.flatMap(r => [r.left, r.right]))].sort((a,b) => a-b);
23
+ const ys = [...new Set(rectangles.flatMap(r => [r.top, r.bottom]))].sort((a,b) => a-b);
24
+ const cells = ys.slice(1).map((_, y) => xs.slice(1).map((_, x) => rectangles.some(r =>
25
+ r.left <= xs[x] && r.right >= xs[x+1] && r.top <= ys[y] && r.bottom >= ys[y+1])));
26
+ const edges = new Map();
27
+ const key = (x,y) => x+','+y;
28
+ const add = (x,y,ex,ey) => {
29
+ const k = key(x,y), list = edges.get(k) || [];
30
+ list.push([ex,ey]); edges.set(k,list);
31
+ };
32
+ for (let y=0;y<cells.length;y++) for (let x=0;x<cells[y].length;x++) {
33
+ if (!cells[y][x]) continue;
34
+ if (!cells[y-1]?.[x]) add(x,y,x+1,y);
35
+ if (!cells[y]?.[x+1]) add(x+1,y,x+1,y+1);
36
+ if (!cells[y+1]?.[x]) add(x+1,y+1,x,y+1);
37
+ if (!cells[y]?.[x-1]) add(x,y+1,x,y);
38
+ }
39
+ const contours = [];
40
+ while (edges.size) {
41
+ const start = edges.keys().next().value.split(',').map(Number);
42
+ const points = []; let current = start, previous;
43
+ do {
44
+ points.push([xs[current[0]],ys[current[1]]]);
45
+ const k = key(...current), next = edges.get(k);
46
+ let index = 0;
47
+ if (next.length > 1 && previous) {
48
+ const dx = current[0]-previous[0], dy = current[1]-previous[1];
49
+ // At a point contact, keep each contour on its own side of the corner.
50
+ index = next.findIndex(p => dx*(p[1]-current[1])-dy*(p[0]-current[0]) > 0);
51
+ if (index < 0) index = 0;
52
+ }
53
+ const target = next.splice(index,1)[0];
54
+ if (!next.length) edges.delete(k);
55
+ previous = current; current = target;
56
+ } while (current[0] !== start[0] || current[1] !== start[1]);
57
+ const corners = points.filter((p,i) => {
58
+ const a = points[(i+points.length-1)%points.length], b = points[(i+1)%points.length];
59
+ return (p[0]-a[0])*(b[1]-p[1]) !== (p[1]-a[1])*(b[0]-p[0]);
60
+ });
61
+ const rounded = corners.map((p,i) => {
62
+ const a = corners[(i+corners.length-1)%corners.length], b = corners[(i+1)%corners.length];
63
+ const before = Math.hypot(p[0]-a[0],p[1]-a[1]), after = Math.hypot(b[0]-p[0],b[1]-p[1]);
64
+ const radius = Math.min(data.radius, before/2, after/2);
65
+ return { p, start: [p[0]+(a[0]-p[0])*radius/before,p[1]+(a[1]-p[1])*radius/before],
66
+ end: [p[0]+(b[0]-p[0])*radius/after,p[1]+(b[1]-p[1])*radius/after], radius,
67
+ turn: (p[0]-a[0])*(b[1]-p[1])-(p[1]-a[1])*(b[0]-p[0]) };
68
+ });
69
+ contours.push(rounded.map((c,i) => (i ? 'L' : 'M')+c.start.join(' ')
70
+ +(c.radius ? 'A'+c.radius+' '+c.radius+' 0 0 '+(c.turn>0 ? 1 : 0)+' '+c.end.join(' ') : '')).join(' ')+'Z');
71
+ }
72
+ return contours.join(' ');
73
+ };
74
+ return () => {
75
+ const measure = layout.cloneNode(true);
76
+ // Exact font declarations are already inline on the typed text children. Copy the inherited
77
+ // flow properties explicitly; the measurement must not inherit the document body's typography.
78
+ const style = getComputedStyle(layout);
79
+ const rootStyle = getComputedStyle(root);
80
+ const offsetX = parseFloat(rootStyle.paddingLeft), offsetY = parseFloat(rootStyle.paddingTop);
81
+ for (const name of ['font-size','line-height','text-align','direction','letter-spacing','word-spacing']) {
82
+ measure.style.setProperty(name,style.getPropertyValue(name));
83
+ }
84
+ measure.style.cssText += ';position:fixed;left:0;top:0;margin:0;padding:0;border:0;box-sizing:content-box;'
85
+ +'width:'+style.width+';height:auto;max-width:none;transform:none;visibility:hidden;pointer-events:none';
86
+ measure.removeAttribute('id');
87
+ for (const child of measure.querySelectorAll('[id]')) child.removeAttribute('id');
88
+ document.body.append(measure);
89
+ let words;
90
+ try {
91
+ const origin = measure.getBoundingClientRect();
92
+ words = [...measure.querySelectorAll('[data-fine-box-word]')].map(word => {
93
+ const range = document.createRange(); range.selectNodeContents(word);
94
+ return [...range.getClientRects()].filter(r => r.width > 0 && r.height > 0).map(r => ({
95
+ left:r.left-origin.left+offsetX, right:r.right-origin.left+offsetX,
96
+ top:r.top-origin.top+offsetY, bottom:r.bottom-origin.top+offsetY,
97
+ }));
98
+ });
99
+ } finally { measure.remove(); }
100
+ const lines = new Map(); let wordIndex = 0;
101
+ for (let index=0;index<paths.length;index++) {
102
+ for (let count=0;count<data.wordCounts[index];count++) for (const rect of words[wordIndex++]) {
103
+ const line = lines.get(rect.top);
104
+ if (!line) lines.set(rect.top,{...rect});
105
+ else { line.left=Math.min(line.left,rect.left); line.right=Math.max(line.right,rect.right);
106
+ line.bottom=Math.max(line.bottom,rect.bottom); }
107
+ }
108
+ paths[index].setAttribute('d',outline([...lines.values()].map(r => ({
109
+ left:r.left-data.paddingX-data.borderWidth/2, right:r.right+data.paddingX+data.borderWidth/2,
110
+ top:r.top-data.paddingY-data.borderWidth/2, bottom:r.bottom+data.paddingY+data.borderWidth/2,
111
+ }))));
112
+ }
113
+ };
114
+ `;
@@ -75,7 +75,7 @@ export const captionFineMarkupSurfaces = [
75
75
  { name: "letter-spacing", required: false, fallback: "0",
76
76
  summary: "Adds pixels of tracking between glyphs." },
77
77
  { name: "word-gap", required: false, fallback: "a quarter of size",
78
- summary: "Sets pixel spacing between Latin words and at Chinese/Latin boundaries. Adjacent Han characters and full-width punctuation have no extra word gap; use letter-spacing for tracking." },
78
+ summary: "Sets the pixel width of authored display separators. Writing-system and numeric boundaries do not create spaces; use letter-spacing for glyph tracking." },
79
79
  { name: "size", required: true,
80
80
  summary: "Sets the type size in pixels." },
81
81
  { name: "kerning", required: false, values: ["auto", "normal", "none"], fallback: "auto",