Step 17b: Pretext — DOM-free text layout with otfm font measurement

Pure SX text layout library with one IO boundary (text-measure perform).
Knuth-Plass optimal line breaking, Liang's hyphenation, position calculation.

Library (lib/text-layout.sx):
- break-lines: Knuth-Plass DP over word widths
- break-lines-greedy: simple word-wrap for comparison
- hyphenate-word: Liang's trie algorithm
- position-line/position-lines: running x/y sums
- measure-text: single perform (text-measure IO)

Server font measurement (otfm):
- Reads OpenType cmap + hmtx tables from .ttf files
- DejaVu Serif/Sans bundled in shared/static/fonts/
- _cek_io_resolver hook: perform works inside aser/eval_expr
- JIT VM suspension inline resolution for IO in compiled code

~font component (shared/sx/templates/font.sx):
- Works like ~tw: emits @font-face CSS via cssx scope
- Sets font-family on parent via spread
- Deduplicates font declarations

Infrastructure fixes:
- stdin load command: per-expression error handling (was aborting on first error)
- cek_run IO hook: _cek_io_resolver in sx_types.ml
- JIT VmSuspended: inline IO resolution when resolver installed
- ListRef handling in IO resolver (perform creates ListRef, not List)

Demo page at /sx/(applications.(pretext)):
- Hero: justified paragraph with otfm-measured proportional widths
- Greedy vs Knuth-Plass side-by-side comparison
- Badness scoring visualization
- Hyphenation syllable decomposition

25 new tests (spec/tests/test-text-layout.sx), 3201/3201 passing.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-12 15:13:00 +00:00
parent f60d22e86e
commit 1eadefd0c1
9 changed files with 274 additions and 52 deletions

View File

@@ -585,7 +585,18 @@ and cek_step_loop state =
(* cek-run *)
and cek_run state =
(let final = (cek_step_loop (state)) in (if sx_truthy ((cek_suspended_p (final))) then (raise (Eval_error (value_to_str (String "IO suspension in non-IO context")))) else (cek_value (final))))
let rec run s =
let final = cek_step_loop s in
if sx_truthy (cek_suspended_p final) then
match !Sx_types._cek_io_resolver with
| Some resolver ->
let request = cek_io_request final in
let result = resolver request final in
run (cek_resume final result)
| None ->
raise (Eval_error (value_to_str (String "IO suspension in non-IO context")))
else cek_value final
in run state
(* cek-resume *)
and cek_resume suspended_state result' =

View File

@@ -246,6 +246,12 @@ exception Parse_error of string
to avoid a dependency cycle between sx_runtime and sx_vm. *)
exception CekPerformRequest of value
(** Hook: resolve IO suspension inline in cek_run.
When set, cek_run calls this instead of raising "IO suspension in non-IO context".
The function receives the suspended state and returns the resolved value.
Used by the HTTP server to handle perform (text-measure) during aser. *)
let _cek_io_resolver : (value -> value -> value) option ref = ref None
(** Hook: convert VM suspension exceptions to CekPerformRequest.
Set by sx_vm after it defines VmSuspended. Called by sx_runtime.sx_apply_cek. *)
let _convert_vm_suspension : (exn -> unit) ref = ref (fun _ -> ())