fix: restore TeX final-pass reachability

This commit is contained in:
Kinneyzhang 2026-08-02 15:30:12 +08:00
parent 665164911f
commit e823d89a4a
31 changed files with 7192 additions and 454 deletions

View File

@ -2,6 +2,10 @@
## phase-kp-overhaul-20260726
- 2026-08-02 complete fixed final-pass emergency stretch and TeX-style
active-path preservation (`task036`; `issue021` closed after user visual
confirmation):
`.phrase/phases/phase-kp-overhaul-20260726/change_final_pass_emergency_stretch_20260802.md`
- 2026-07-28 repository system audit:
`.phrase/phases/phase-kp-overhaul-20260726/change_repository_audit_20260728.md`
- 2026-07-28 complete DP cache signature (`task002`):
@ -82,6 +86,18 @@
projection reuse (`task030` implementation verified; locked
source-instrumented 16 ms gate and `issue018` remain open):
`.phrase/phases/phase-kp-overhaul-20260726/change_narrow_live_append_latency_20260730.md`
- 2026-08-01 implement and developer-verify the rigid-inline-atom
emergency-break correction (`task033` complete; `issue021` awaits user
confirmation):
`.phrase/phases/phase-kp-overhaul-20260726/change_overlong_no_break_atom_20260801.md`
- 2026-08-01 implement configurable break policies, record G002-G008
implementation/GUI/performance/repository/cleanup/review-blocker evidence,
and close developer work for `task034` (`issue021` still awaits user visual
confirmation; final independent code review APPROVE and architecture CLEAR):
`.phrase/phases/phase-kp-overhaul-20260726/change_configurable_break_policies_20260801.md`
- 2026-08-02 record the failed line-width emergency-stretch attempt
(`task035` closed as falsified; superseded by completed `task036` above):
`.phrase/phases/phase-kp-overhaul-20260726/change_final_pass_emergency_stretch_20260802.md`
## phase-doc-improvement-20260125
- See `.phrase/phases/phase-doc-improvement-20260125/change_log.md`

View File

@ -20,3 +20,4 @@
- issue018 [ ] [Unique live appends miss the frame budget at very narrow widths.](../phases/phase-kp-overhaul-20260726/issue_narrow_live_append_latency_20260729.md)
- issue019 [ ] [Per-edit frontier replanning destabilizes live projection and cannot restore reversible edits exactly.](../phases/phase-kp-overhaul-20260726/issue_stable_live_edit_transaction_20260729.md)
- issue020 [ ] [C-backed resize reflow still takes roughly 6070 ms.](../phases/phase-kp-overhaul-20260726/issue_c_resize_latency_20260729.md)
- issue021 [x] [Final-pass emergency layout can isolate CJK source lines at narrow widths.](../phases/phase-kp-overhaul-20260726/issue_overlong_no_break_atom_20260801.md)

View File

@ -0,0 +1,175 @@
# Change: Configurable Break Policies 2026-08-01
Superseded note: the historical hard-atom adjacency rule recorded below was
removed by `task036`. Explicit atoms forbid only interior breaks; current
final-pass reachability semantics are recorded in
`change_final_pass_emergency_stretch_20260802.md`.
## 2026-08-01 — Plan task034 and record the red baseline
- **Add** `task034` for configurable break policies and the remaining
orphan-glyph quality failure in the 280px showcase paragraph.
- **Modify** the text-property layout spec to make inline code wrapping,
token policies, kinsoku profiles, overlong-token behavior, buffer measure,
and region break-policy precedence explicit.
- **Modify** the text-property layout plan with M13, preserving the approved
architecture: resolve policy intervals before tokenization, compile to the
existing hyphen/break vectors before DP, and keep the C boundary at 15
arguments and 15 batch fields.
- **Red baseline:** the latest screenshot shows the previous atom cascade is
gone, but the crafted inline-code paragraph still permits pathological
single-CJK source lines around the atom, including `行`, `内`, and `永`.
The implementation oracle must reject any such single-CJK source line when
a legal non-emergency alternative exists.
- **Verification contract:** focused RED/GREEN ERT for core, buffer, command,
cache, and diagnostics; C/Elisp parity; 300-case property fuzz;
warning-as-error Elisp compilation; C builds/tests; checkdoc/package/static
release gates; and reviewed fullscreen GUI evidence for inline wrapping,
no orphan glyph, explicit no-break, block verbatim, measure modes, and
overlong-token modes.
- **Behavior/Risk:** Planning records only. Runtime behavior is unchanged.
This entry opened `task034`; later entries record its implementation and
closure.
## 2026-08-01 — Record task034 implementation and G004 GUI evidence
- **Modify** `task034`, M13, and the text-property layout spec with verified
implementation facts from G002/G003/G004. At this point `task034` was still
open pending later gates; G006 closure is recorded below.
- **Implementation evidence recorded:** core policy compilation preserves the
existing C boundary; buffer policy ownership separates block skip faces from
inline faces; public local/profile/region controls and diagnostics exist;
showcase verification distinguishes automatic inline code from explicit
`ekp-no-break` and verbatim block code.
- **GUI evidence recorded:** G004 passed GUI verifier ERT 7/7 and the clean
single-window evidence run at `/tmp/ekp-g004-evidence.Tp77dW` reports 12/12
checkpoints with no failed assertions, 25.75s/206-frame recording, no black
segments, automatic inline wrapping 3→2→3 lines across 280→340→280, every
internal inline split as source whitespace, exact source, zero overlays, C
active, and a settled no-hyphen→normal policy transition with stale
nil-plan spans at zero.
- **Verification status:** G005 repository-wide gates and G006 cleanup are
recorded below.
- **Behavior/Risk:** Documentation synchronization only in this entry. Do not
close `task034` or any user-confirmation issue from this G004-only evidence
at this point in the sequence.
## 2026-08-01 — Record G005 final performance and repository gate evidence
- **Modify** `task034` and the policy-boundary postmortem with the confirmed
G005 root causes and final gate evidence. At this point `task034` was still
open pending G006 cleanup; G006 closure is recorded below.
- **Root causes recorded:** paragraph identity included unconditional policy
measure, causing paragraph rebuilds; volatile property clearing fragmented
equal property runs; policy analysis ran before paragraph-cache hits; and
repeated same-paragraph/same-width requests duplicated semantic plan
assembly.
- **Fix evidence recorded:** policy measure was removed from the semantic
signature; adjacent equal filtered intervals are canonicalized; policy full
analysis now uses a bounded two-tier cache keyed by clean source, canonical
layout and raw face policy intervals, policy signature, and width context,
with a measure tier only for actual width-sensitive no-break/overflow
candidates; and semantic plans use a bounded per-paragraph plan cache. G007
later strengthened the consumer boundary from shallow copies to
consumer-owned copies of plan-owned mutable payloads; see the G008 entry
below.
- **Performance evidence recorded:** formal four-interleaved evaluator reports
layout parity true. Core baseline p50/p95 was 38.6679/51.6782ms and
candidate p50/p95 was 21.7102/33.0040ms, for 43.8549%/36.1354% gains.
Resize baseline p50/p95 was 43.3831/55.6250ms and candidate p50/p95 was
22.0919/32.8202ms, for 49.0773%/40.9973% gains. Both candidate p95 values
are under 50ms and both gain sets exceed 20%.
- **Live evaluator recorded:** the source-instrumented locked goal remains the
known `validation_failed` debt; parity, zero-work, GC, conflict, and
all-width-nonregression checks are true. Current C p95/p99 is
26.449/26.740ms, a 76.65%/77.23% improvement. Current Elisp p95/p99 is
49.940/52.017ms, a 91.64%/91.35% improvement. This is consistent with
historical open `issue018` and is not a regression.
- **Production public-path evidence recorded:** three byte-compiled runs
passed with zero GC. C append p99 was 1.361-1.368ms and hard p99 was
1.876-1.891ms. Elisp append p99 was 1.692-1.775ms and hard p99 was
2.100-2.230ms.
- **Verification recorded:** default ERT passed 262/262; seeded permuted ERT
seed 20260728 passed 255/255; isolated per-test process suite exited 0;
the subsequently added alias guard passed targeted verification; property
fuzz passed 300/300; warning-as-error byte compilation passed; pinned
package-lint at `35996f478d81e51dae4fa30d051f741895d07399` exited 0 with
only an external obsolete warning from the local names dependency; checkdoc
was empty; release, 49-entry dictionary manifest, pinned dictionary update,
shell syntax, CI YAML, and diff-check passed; portable, native, debug, and
sanitize C builds were warning-clean; module 1.6/4-thread smoke passed; and
focused C verification passed 19/19 including the 15-argument/15-field
boundary.
- **Verification status:** G005 gates are complete. G006 cleanup is recorded
below.
## 2026-08-01 — Close task034 with G006 cleanup evidence
- **Modify** `task034`, M13, the global change index, and the policy-boundary
postmortem with G006 cleanup evidence.
- **Cleanup evidence recorded:** G006 edited only `ekp.el` and
`ekp-buffer.el`, removing redundant policy/cache code without changing the
locked behavior.
- **Verification recorded:** targeted cleanup suites passed 6/6 and 3/3; full
core ERT passed 125/125; full buffer ERT passed 120/120; warning-as-error
byte compilation passed; diff-check passed; and the C build gate passed.
- **Behavior/Risk:** `task034` is closed for developer implementation,
repository gates, performance gates, and cleanup gates. `issue021` remains
open pending user visual confirmation. Independent final code/architecture
review has not yet been claimed.
## 2026-08-01 — Resolve G006/G007 review blockers with G008 ownership fixes
- **Modify** `task034`, M13, developer documentation, changelog, and
postmortems with the G007/G008 ownership and backend-contract corrections.
- **Review blockers resolved:** G006 cleanup's independent review found that
shallow semantic-plan copies left nested line/gap/glue payloads mutable
through cache hits, and that malformed non-nil C results could still be
treated too softly. G007 changed the semantic-plan cache boundary to return
consumer-owned copies of every plan-owned mutable payload: source string,
context, boxes, offsets, lines, glues, gaps, and signatures. `para` remains
intentionally shared because paragraph-cache ownership and append identity
rely on that object. G007 final review then found one remaining alias:
`copy-tree` did not copy strings inside the returned context, so mutating a
dynamic policy suffix from the returned plan could poison the cached context.
G008 resolves that final blocker by using the recursive context copier for
cons/vector/string payloads both when the context snapshot/cache key is
created and when a plan is returned.
- **Backend contract recorded:** only a nil whole C result or nil per-item
breaks may fall back to Elisp. Any malformed non-nil single or batch output
signals `ekp-backend-contract-error`. The C entry remains 15 arguments and
the batch payload remains 15 fields.
- **Hard-atom boundary recorded:** the ordinary boundary immediately before a
following explicit `ekp-no-break` atom is forbidden before DP. This direct
pre-DP adjacency rule is separate from the final-pass emergency-stretch
model and is locked by a focused regression.
- **Verification recorded:** formal resize parity is true; core baseline
p50/p95 is 36.595/48.357ms and candidate p50/p95 is 23.266/36.363ms, for
36.42%/24.80% gains. Resize baseline p50/p95 is 41.443/53.541ms and
candidate p50/p95 is 23.761/36.889ms, for 42.67%/31.10% gains. ERT passes
268/268; property fuzz passes 300/300; release gates pass; byte-compiled
current public path records zero GC with C append/hard p99
1.440-1.464/2.022-2.050ms and Elisp append/hard p99
1.648-1.687/2.136-2.335ms, all below 16ms.
- **Behavior/Risk:** G007 resolved the first recorded G006 review blockers,
and G008 resolves the final string-leaf context blocker, but this change
record does not claim final independent APPROVE/CLEAR. `issue021` remains
open pending user visual confirmation, and historical `issue018` remains
open.
## 2026-08-02 — Record final independent review clearance
- **Review remediation:** the first final code-review pass found warning-only
defects in changed test fixtures and a stale C README setter example. The
fixtures now byte-compile with warnings as errors, and the README documents
the optional eighth `EMERGENCY-STRETCH` argument.
- **Verification:** the remediation-focused ERT passes 5/5, full ERT passes
288/288, every changed test file compiles warning-clean, and diff-check
passes.
- **Independent verdicts:** the code-reviewer re-review returns `APPROVE` with
zero findings; the subsequent architect review returns `CLEAR` and confirms
core K-P ownership, content-independent final-pass reachability, Elisp/C
parity, stable ABI, cache ownership, and complete nonempty GUI oracles.
- **Behavior/Risk:** `task034` and its independent developer gate are
complete. `issue021` closed after the user's 2026-08-02 visual
confirmation, and historical `issue018` remains open.

View File

@ -0,0 +1,91 @@
# Change: Final-Pass Emergency Stretch 2026-08-02
## 2026-08-02 — Close issue021 after user visual confirmation
- **Modify** the global issue index and issue detail to close `issue021`.
- **User confirmation:** the user accepted the visible result with “可以了,
提交吧” and requested the commit.
- **Behavior/Risk:** no runtime change; this closes the required user-visible
acceptance gate after the developer, dynamic GUI, and independent review
gates had already passed.
## 2026-08-02 — Complete task036 with TeX active-path preservation
- **Modify** `ekp.el` and `ekp_c/ekp_kp.c`: keep strict K-P unchanged; use
fixed `ekp-emergency-stretch-pixel` for ordinary final-pass underfull
candidates; and, only when an overfull candidate would otherwise extinguish
the final active path to a breakpoint, install the best provisional path
with tight fitness and zero incremental demerits.
- **Delete** the wrong-layer rule that forbade an otherwise legal boundary
immediately before an explicit hard atom. Hard atoms forbid only their
interior breaks; an overwide atom remains intact but need not stand alone.
- **Modify** the renderer to distribute the chosen line's actual rest using
its actual TeX glue-set proportions. Elisp 1D, looseness/parshape, and C
behavior match; the public 15-field/15-argument C contract is unchanged.
- **Strengthen tests** so isolated-CJK oracles require a nonempty plan that
exactly covers the source, preventing an empty result from false-greening.
- **Review remediation:** make the changed test fixtures warning-clean under
`byte-compile-error-on-warn` and document the optional eighth
`EMERGENCY-STRETCH` argument in the C README API example.
- **Verification:** focused root regressions 8/8, emergency selector 10/10,
core/buffer/GUI oracle 8/8, full ERT 288/288, seeded and isolated suites
279/279, fuzz 300/300, warning-clean portable/native/debug/sanitize C
builds, warning-as-error production and changed-test Elisp compilation,
checkdoc/package/static/release gates, formal performance gates, and
reviewed 42.78-second dynamic GUI evidence at
`/tmp/ekp-g009-evidence-retry.UOpPNp` with `VERDICT=PASS`. Final independent
code review returns `APPROVE` and architecture review returns `CLEAR`.
- **Behavior/Risk:** no CJK-orphan, unit, atom-adjacency, or screenshot
heuristic exists. `task036` is complete; `issue021` closed after the
user's 2026-08-02 visual confirmation.
The entries below are historical steps. Their hard/atomic fixed-cost wording
was superseded by the completed task036 semantics above.
## 2026-08-02 — Open task036 after task035 full-regression failure
- **Modify** `task035` to record the line-width-sized emergency stretch as a
falsified attempt, not accepted current behavior.
- **Add** `task036` for TeX-style fixed-dimension final-pass emergency
stretch. The implementation must expose `ekp-emergency-stretch-pixel`
where nil auto-resolves to roughly three display-font `M` widths and a
non-negative integer fixes the pixel value.
- **Preserve** the constraints: strict pass unchanged, 15-field C paragraph
ABI unchanged, renderer semantics synchronized, fixed artificial emergency
transition only for truly overfull first permitted hard/atomic runs, and no
CJK-orphan/unit/screenshot-specific penalty.
- **Verification planned:** five new RED cases for fixed dimension behavior,
nil auto, integer override, C parity without ABI growth, and renderer
width/glue consistency; the three regressions broken by task035; focused
G009 GUI/core/public-buffer/C parity; final fullscreen dynamic GUI evidence;
full ERT/fuzz/build/static/release gates.
- **Behavior/Risk:** Documentation records the next atom of work only.
Runtime behavior is not claimed fixed by this entry.
## 2026-08-02 — Close task035 as falsified
- **Modify** core K-P documentation to state the attempted final-pass model:
strict pass unchanged; final pass gives ordinary underfull candidates a
line-width-sized finite background emergency stretch; those candidates
still use adjustment ratio, badness, fitness, and demerits.
- **Modify** user-facing hard-atom documentation to avoid claiming a
hard-coded fullest-prefix fallback. A hard atom may occupy one overflow
line, while the ordinary prefix remains a normal K-P decision with finite
emergency stretch.
- **Modify** `issue021`, `task035`, the spec, plan, changelog, developer
docs, C README, and global change/issue indexes to record the corrected
owner and current acceptance boundary.
- **Add** `postmortem/20260802-final-pass-emergency-stretch.md` to explain
why the ordinary-underfull fixed fallback was the wrong layer, why the
TeX-style emergency-stretch model is used, and why the earlier 84px oracle
was invalid.
- **Verification:** Focused core single-CJK regressions pass 2/2. The
implementation lane reports focused G009 core/C/hard-atom/buffer/GUI-oracle
coverage 18/18, warning-as-error byte compilation pass, and C build pass.
Full regression then falsified the line-width stretch detail and forced the
`task036` fixed-dimension follow-up. This documentation pass ran stale-text
search and `git diff --check`.
- **Behavior/Risk:** `task035` is not current truth. No public configuration,
C ABI, or renderer ownership change is accepted by this record. `issue021`
remains open until task036 and the final user-visible visual acceptance gate
pass.

View File

@ -0,0 +1,44 @@
# Change: Rigid Inline Atom Break Cascade 2026-08-01
Superseded note: this file records the earlier task033 repair. The current
ordinary-underfull final-pass model is recorded in
`change_final_pass_emergency_stretch_20260802.md`; an overwide atom remains
intact but is not guaranteed a standalone line, and atom adjacency has no
special break rule.
## 2026-08-01 — Implement and developer-verify task033
- **Modify** `ekp.el` so the emergency pass preserves its established first
transition but adds a fullest-underfull prefix fallback only when a
forbidden break run jumps directly to an overfull permitted candidate.
- **Modify** `ekp_c/ekp_kp.c` with the same state transition and keep the
looseness/parshape Elisp path in parity.
- **Add** core, public-buffer, and GUI regressions covering diverse CJK
prefixes, exact source boundaries, atom integrity, Elisp/C parity, and
repeated 480→280→340→280 reflow.
- **Modify** user/developer documentation and add
`postmortem/20260801-forbidden-run-emergency-boundary.md` to record why the
broader fullest-prefix replacement was rejected.
- **Verification:** RED reproduced boundary 1 instead of 11. GREEN passes
normal, random-order, and isolated 201-test runs; 300 C/Elisp fuzz cases;
warning-as-error Elisp compilation; package-lint; checkdoc; release and
dictionary gates; warning-clean debug/sanitize/portable C builds; and
reviewed dynamic GUI evidence at
`/private/tmp/ekp-atom-gui-final-Beg8hb` with verdict PASS.
- **Behavior/Risk:** Strict K-P, ordinary emergency layouts, buffer
projection, source text, and public APIs are unchanged. The additional
relaxation is limited to emergency scans that cross forbidden break
positions before an overfull candidate.
## 2026-08-01 — Plan issue021 and task033
- **Add** `issue021` with the 280px showcase reproduction and deterministic
width-40 batch analogue.
- **Add** `task033` and M12 acceptance gates for the core emergency-break
correction, Elisp/C parity, and real GUI verification.
- **Modify** the text-property layout spec to require a full ordinary prefix
line and one intact rigid atom line rather than one-glyph emergency
fragmentation.
- **Behavior/Risk:** Planning records only. Runtime behavior is unchanged.
The change will affect only the second pass used after strict K-P has
already proved the paragraph unreachable.

View File

@ -0,0 +1,69 @@
# Issue: Final-Pass CJK Isolation 2026-08-01
## issue021 [x] Final-pass emergency layout can isolate CJK source lines at narrow widths
- **Status:** Resolved. Developer verification and independent review passed;
the user accepted the visible result and requested the commit.
- **Summary:** At narrow measures, the final K-P pass can choose one-glyph CJK
source lines in the mixed showcase paragraph. The visible symptom was first
found around an explicit hard atom, then reproduced after automatic inline
policy was split from hard atoms.
- **Environment:** Graphical Emacs 30.2 on macOS, narrow showcase widths,
justify alignment, C module active for 1D layout. The same core problem is
visible through the public buffer path, so this is not a renderer-only
issue.
- **Repro:** Run `M-x ekp-showcase`, set a narrow width such as 168px or
280px, and inspect the mixed Chinese/Latin inline paragraph. The failure
appears when a CJK glyph becomes a complete source line even though the
paragraph has legal non-emergency alternatives.
- **Expected vs Actual:** Ordinary underfull candidates in the final pass
should remain normal K-P candidates: adjustment ratio, badness, fitness,
and demerits decide the global path. Separately, the final pass must not
lose all reachability when a legal candidate is overfull. The previous
model confused that reachability safeguard with a hard/atomic fixed-cost
scoring shortcut, and an empty-plan oracle could hide the failure.
- **Investigation:** The strict pass is not the bug. The buffer projector
reproduces the semantic plan exactly and owns no line-choice compensation.
The root is in core DP final-pass modeling: ordinary underfull candidates
must receive finite emergency stretch and stay inside normal K-P scoring.
TeX's `artificial_demerits` instead preserves the last active path in the
final pass independent of content class; it is not owned by CJK, units,
hard atoms, or their adjacency.
- **Required Outcome:** Eliminate unintended isolated CJK source lines at the
checked narrow showcase widths without adding CJK-orphan, number-unit, or
screenshot-specific penalties. Preserve source text, zero overlays,
strict-pass behavior, Elisp/C parity, and the public C ABI.
- **Fix Plan:** Keep strict K-P unchanged. In the final pass, add a
TeX-style fixed emergency-stretch dimension to ordinary underfull
candidates and score them through normal badness, fitness, and demerits.
Expose the dimension as `ekp-emergency-stretch-pixel`: nil auto-resolves to
roughly three display-font `M` widths, while a non-negative integer fixes
the pixel value. If an overfull candidate would otherwise remove the final
active path to a breakpoint and no non-overfull candidate survives there,
install the best provisional path with tight fitness and zero incremental
demerits. The Elisp 1D path, looseness/parshape path, renderer semantics,
and C engine share this rule without changing the 15-field C paragraph ABI.
- **Verification:**
- Focused core single-CJK regressions pass 2/2.
- The implementation lane's focused G009 suite covering core, C parity,
hard atoms, public buffer projection, and GUI oracle passes 18/18.
- Warning-as-error Elisp byte compilation and the C build pass.
- Full regression falsified task035's line-width-sized emergency stretch:
the stretch must be a fixed dimension, not derived from each candidate
measure.
- Task036's fixed-dimension RED cases pass and the three regressions broken
by task035 remain green. Direct regression proves artificial lines add
zero demerits and preserve a complete nonempty source-covering plan.
- Focused root regressions pass 8/8, emergency selection passes 10/10,
core/buffer/GUI oracle passes 8/8, full ERT passes 288/288, seeded and
isolated suites pass 279/279, fuzz passes 300/300, and all build/static/
release/performance gates pass.
- Reviewed 42.78-second fullscreen single-window evidence at
`/tmp/ekp-g009-evidence-retry.UOpPNp` covers 480→168→280→168 and
no-hyphen→normal policy change, reports no isolated CJK source lines,
exact source, zero overlays/stale spans, active C, and `VERDICT=PASS`.
- **User Confirmation:** On 2026-08-02, the user replied “可以了,提交吧”,
accepting the visible result and requesting submission.
- **Resolved At:** 2026-08-02.
- **Resolved By:** `task036`; final-pass active-path preservation in core K-P.
- **Commit:** This commit (`fix: restore TeX final-pass reachability`).

View File

@ -38,6 +38,12 @@ hyphens. No buffer path may use overlays.
character-modified tick, point, mark, or external modification hooks.
- Turning the mode off removes only EKP-owned projection properties and
restores every pre-existing property exactly.
- Final-pass emergency layout remains owned by core K-P. Ordinary underfull
candidates use finite emergency stretch and normal badness/demerits; the
final active path is preserved with TeX-style zero-increment artificial
demerits only when an overfull candidate would otherwise extinguish it.
The buffer renderer and policy compiler must not add CJK-orphan, unit,
hard-atom-adjacency, or screenshot-specific compensation.
## Architecture
@ -260,6 +266,128 @@ rows are not the durable planning unit.
complete resize improved by 43.51%/41.03% to 15.900/27.487 ms. Exact
frozen-C/Elisp parity and all automated, static, and GUI gates pass.
### M12 — Rigid inline atom emergency breaking
- Status: `task033` implemented and developer-verified as the earlier
rigid-atom repair. `task036` supersedes its ordinary-underfull emergency
model; `issue021` was closed after user-visible confirmation.
- Reproduce the showcase's narrow CJK prefix plus `ekp-no-break` atom through
the public string and semantic-plan paths before changing the algorithm.
- Historical task033 design: keep the strict K-P pass and the established
first-candidate emergency transition unchanged, then add a narrow
forbidden-run prefix fallback. The current task036 model replaces that
fallback with fixed final-pass emergency stretch plus content-independent
active-path preservation.
- Preserve atom integrity, source text, Elisp/C parity, looseness/parshape
semantics, append correctness, and the buffer projection contract.
- Gate: focused RED/GREEN core and buffer regressions, complete ERT in normal
and random order, isolated tests, C/Elisp fuzz, warning-as-error builds,
static/release checks, and clean static plus dynamic 280px GUI evidence.
- Result: the failing boundary moved from 1 to the full prefix boundary 11 in
Elisp, C, and public buffer paths. Normal/random/isolated ERT pass 201/201,
fuzz passes 300/300, static and build gates pass, and reviewed
480→280→340→280 GUI evidence returns PASS.
### M13 — Configurable break policies and orphan-glyph closure
- Status: `task034` implementation, focused automated evidence, and G004 GUI
evidence are recorded. G005 repository-wide gates and G006 cleanup are
complete. `task034` is closed for developer work, `issue021` is closed
after user visual confirmation, and independent final code review returned
APPROVE with architecture status CLEAR.
- Implement the locked A2/B2/C2/D1/E1/F1/G2/H1/I1/J1/K1/M1 contract:
inline code defaults to no-hyphen rather than no-break, known inline faces
are recognized through mode profiles, region policy outranks explicit
local values which outrank mode profiles and globals, block faces remain
verbatim, explicit no-break never downgrades, automatic no-break downgrades
to no-hyphen when overwide, inline and block faces use separate paths,
manual properties stay session-only, hyphenation defaults to auto,
URL/path/identifier default to no-hyphen, compact number-unit defaults to
no-break, kinsoku defaults to common, overlong tokens default to emergency,
and buffer measure defaults to the narrowest live window.
- Keep the chosen architecture: resolve private structural policy intervals
before tokenization, compile them into existing hyphen positions and
forbidden-break vectors before DP, preserve the original source in the
semantic plan, and keep the C boundary at the current 15 arguments and
15-field batch payload unless architecture review is reopened.
- Split block and inline ownership in `ekp-buffer`: `ekp-buffer-skip-faces`
remains paragraph-level verbatim, `ekp-buffer-inline-faces` annotates only
exact inline intervals, and `ekp-buffer-mode-policy-alist` is consulted
without auto-copying profile values into buffer locals.
- Add public region controls for `ekp-break-policy`: normal, enable
hyphenation, disable hyphenation, and clear. Existing no-break/verbatim
commands remain the only hard-atom and paragraph-bypass controls.
- Fix the remaining visible quality bug from the user's latest screenshot:
the 280px showcase path must not isolate any pathological single-CJK source
line around inline code, including `行`, `内`, or `永`, when a legal
non-emergency alternative exists.
- Gate: follow the RALPLAN test specification R1-R8 plus GUI verification.
Required evidence includes focused RED/GREEN ERT, full normal/permuted/
isolated ERT, 300-case property fuzz, C/Elisp parity, warning-as-error
Elisp compilation, C builds/tests, checkdoc/package/static/release gates,
and reviewed fullscreen single-window screenshots for inline wrapping,
no orphan glyph, explicit no-break, block verbatim, measure modes, and
overlong-token modes.
- Current evidence: G002 records core policy compilation, C parity, 300-case
fuzz, byte compilation, checkdoc, C build, and focused policy/cache gates.
G003 records buffer/profile/local/region controls, diagnostics, generation
reflow, source/editor invariants, focused core/buffer suites, byte
compilation, and checkdoc. G004 records GUI verifier ERT 7/7 and a clean
single-window run at `/tmp/ekp-g004-evidence.Tp77dW` with 12/12 checkpoints,
no failed assertions, 25.75s/206-frame recording, no black segments,
automatic inline wrapping 3→2→3 lines across 280→340→280, source-space
internal inline breaks, explicit hard atom, block verbatim, C active, and
no stale policy projection after the no-hyphen→normal transition.
- Final evidence: G005 recorded repository-wide default/seeded/isolated ERT,
property fuzz, warning-clean Elisp/C builds, checkdoc/package/release/
dictionary/static gates, performance checks, and full diff review. G006
cleanup touched only `ekp.el` and `ekp-buffer.el`; targeted cleanup suites
passed 6/6 and 3/3, full core ERT passed 125/125, full buffer ERT passed
120/120, and byte compilation, diff-check, and C build gates passed. G006
was then review-blocked on nested semantic-plan cache aliasing and malformed
non-nil C fallback semantics. G007 resolved those first blockers: cached
semantic plans return consumer-owned copies of plan-owned mutable payloads
while intentionally sharing `para`; malformed non-nil C single/batch output
signals `ekp-backend-contract-error`, with nil-result Elisp fallback and
the 15-argument/15-field ABI unchanged. G007 final review found one
remaining string-leaf context alias; G008 resolves it by recursively
copying cons/vector/string context payloads both for snapshot/cache-key
creation and returned plans. Fresh G008 gates record formal resize parity
true, ERT 268/268, fuzz 300/300, release pass, core p50/p95
23.266/36.363ms, resize p50/p95 23.761/36.889ms, and byte-compiled current
public path p99 below 16ms with zero GC. After review remediation made all
changed tests warning-clean and synchronized the eighth C setter parameter,
final independent code review returned APPROVE and architecture review
returned CLEAR.
### M14 — Final-pass emergency stretch
- Status: `task035` was falsified; `task036` is implemented and its developer
gates pass. `issue021` is closed after user-visible confirmation.
- Correct the core K-P owner, not the renderer or policy compiler. Strict
pass behavior remains unchanged.
- In the final pass, ordinary underfull candidates receive finite background
emergency stretch and still compute adjustment ratio, badness, fitness, and
demerits. Separately, when an overfull candidate would otherwise eliminate
the final active path to a breakpoint and no non-overfull candidate survives
there, install the best provisional path with tight fitness and zero
incremental demerits, matching TeX's `artificial_demerits` purpose.
- Do not add CJK-orphan, unit, or screenshot-specific penalties. Unit suffix
configuration remains only a token-classification input, not a layout
scoring rule.
- Gate: focused core/public-buffer/C parity tests and GUI-oracle checks must
reject any isolated CJK source line in the showcase paragraph at checked
widths. Final fullscreen visual review remains required before closing
`issue021`.
- Result: the Elisp 1D, looseness/parshape, and C paths share the same rule;
explicit atom interiors remain unbreakable but adjacent legal boundaries
remain legal; the public 15-field/15-argument C contract is unchanged.
Focused regressions pass 8/8, the emergency selector passes 10/10, the
core/buffer/GUI oracle passes 8/8, full ERT passes 288/288, seeded and
isolated core suites pass 279/279, fuzz passes 300/300, all build/static/
release gates pass, performance gates pass, and reviewed dynamic GUI
evidence at `/tmp/ekp-g009-evidence-retry.UOpPNp` returns PASS.
## Stop Gates
- Stop the affected paragraph instead of stealing a foreign replacing

View File

@ -141,6 +141,42 @@ overlay and inserts no layout character.
state.
- Major-mode changes and mode disable leave the logical text and prior
integrations intact.
- A rigid inline atom that jumps a candidate line from underfull to overfull
stays intact without forcing the preceding prose through one-box emergency
lines. In the final pass, ordinary underfull candidates receive finite
emergency stretch and remain normal K-P cost candidates. An atom wider than
the measure stays intact but may share an overflow line with preceding
ordinary content when TeX-style artificial demerits must preserve the last
active path. Atom adjacency has no special break or scoring rule.
- Inline code is not rigid by default. Known Org/Markdown inline faces use
the inline policy path, not the paragraph verbatim path. The default policy
permits legal wrapping at existing boundaries, suppresses discretionary
dictionary hyphenation, and preserves source whitespace literally. Known
code-block faces remain paragraph-level verbatim.
- Manual `ekp-no-break` is the only explicit hard-atom owner and is never
downgraded. Automatic no-break spans, such as compact number-unit tokens,
downgrade to no-hyphen when wider than the effective measure.
- The previous narrow showcase orphan-glyph failure is now a permanent
acceptance invariant: the inline-code paragraph must not isolate CJK glyphs
such as `行`, `内`, or `永` as one-character source lines around automatic
inline code when a non-emergency legal alternative exists. Dynamic evidence
must distinguish automatic inline code from explicit hard atoms and block
verbatim spans.
- The GUI oracle rejects any isolated CJK source line in the showcase
paragraph at the checked widths. This is a core K-P invariant, not a
unit-suffix, screenshot-specific, or renderer compensation rule.
- Effective policy precedence is deterministic: region properties, then
explicit buffer/file/dir-local values, then major-mode profiles, then
global defaults. Manual text properties are session-local; persistence
comes from mode syntax/profiles and file/dir locals.
- URL, path, and identifier tokens default to no-hyphen. Compact number-unit
tokens default to no-break. Hyphenation defaults to `auto`, with `on` and
`off` overrides. Kinsoku defaults to `common`, with `zh`, `ja`, `off`, and
custom additions available. Ordinary overlong tokens default to the current
emergency output, with `overflow` and `natural` alternatives.
- The buffer measure defaults to the narrowest live window. A positive
integer fixed measure and `(max . PIXELS)` cap are configurable and must be
reported by diagnostics when they create overflow risk.
- Reprojection preserves point, the mark marker, and `mark-active`
independently. An inactive historical mark must never become a visible
selection merely because width or layout options changed.
@ -190,3 +226,19 @@ overlay and inserts no layout character.
9. Default, permuted, and isolated ERT; C/Elisp fuzz; warning-as-error
Elisp/C builds; checkdoc; package/static/release gates; full diff review;
independent code review; and independent architecture review pass.
10. Elisp, C, semantic-plan, string-renderer, and real GUI paths keep an
`ekp-no-break` atom intact while proving that its preceding CJK prefix is
not fragmented into one-glyph lines at narrow measures.
11. Inline and token break policies are configurable at global, mode profile,
explicit local, and region scopes. The accepted defaults are: inline code
`no-hyphen`; block code verbatim; URL/path/identifier `no-hyphen`;
compact number-unit `no-break`; hyphenation `auto`; kinsoku `common`;
overlong token `emergency`; buffer measure `narrowest-window`.
12. Region `ekp-break-policy` supports `normal`, `hyphenate`, and
`no-hyphen`, and never creates a second hard-atom representation.
Overlapping explicit `ekp-no-break` wins over every new policy.
13. The showcase paragraph containing inline code, CJK prose, NBSP-backed
numbers, and units must have no pathological single-CJK source line in
the inspected 280px GUI path while retaining exact source text, zero
overlays, block-code verbatim display, internal source-space inline
breaks, and C/Elisp plan parity.

View File

@ -722,3 +722,205 @@
checkdoc, release checks, and reviewed dynamic GUI evidence pass.
- Closure: developer verification is complete. `issue020` remains open
only for the required user-visible confirmation.
- task033 [x] Prevent rigid inline atoms from fragmenting preceding CJK.
- Source: `issue021`, the user's 2026-08-01 showcase screenshot, and
`plan_text_property_layout_engine_20260729.md` M12.
- Red tests: reproduce the deterministic batch underfull-to-overfull jump
and the real 280px showcase path; require the fullest permitted prefix
line, one intact atom line, and matching Elisp/C plans.
- Work: correct the emergency transition owner in core DP and its C/loose
equivalents without changing strict K-P results or buffer projection.
- Validation: focused RED/GREEN core and buffer ERT, normal/random/
isolated full ERT, C/Elisp fuzz, warning-as-error compilation, checkdoc,
release/static gates, and inspected static/dynamic GUI evidence.
- Impact: core line breaking, C parity, regression tests, user/developer
documentation, issue/change records, and no new public API.
- Result: retain the original emergency transition and add a fallback only
for a forbidden-run underfull-to-overfull jump. This was later superseded
by the accepted `task036` final-pass model; strict K-P and buffer
projection remain unchanged.
- Verification: the RED boundary was 1 instead of 11. Normal,
seeded-random, and isolated ERT pass 201/201; property fuzz passes
300/300; Elisp/static/release/dictionary and three C profile builds pass.
Reviewed 62.7-second GUI evidence at
`/private/tmp/ekp-atom-gui-final-Beg8hb` passes all nine checkpoints with
an intact atom, one-line full prefix, exact source, zero overlays, active
C engine, and no black or transient vertical frame.
- Closure: developer verification is complete. `issue021` remains open
only for the required user-visible confirmation.
- task034 [x] Make break policy configurable and eliminate inline-code
induced orphan glyph lines.
- Source: the approved RALPLAN PRD
`.omx/plans/prd-ekp-configurable-break-policies.md`, test spec
`.omx/plans/test-spec-ekp-configurable-break-policies.md`, and the
user's 2026-08-01 follow-up screenshot showing the showcase no longer
collapses into the previous one-glyph cascade but still permits
pathological single-CJK source lines around the inline atom, including
`行`, `内`, and `永`.
- Work: implement the locked A2/B2/C2/D1/E1/F1/G2/H1/I1/J1/K1/M1 policy
contract. Inline code defaults to legal wrapping with no dictionary
hyphenation; block code remains verbatim; explicit `ekp-no-break` stays
rigid forever; automatic no-break downgrades to no-hyphen when wider than
the effective measure; URL/path/identifier default to no-hyphen; compact
number-unit tokens default to no-break; kinsoku defaults to `common`;
ordinary overlong tokens default to current emergency output; and buffer
measure defaults to the narrowest live window with fixed and max-cap
alternatives.
- Public configuration: add global, major-mode profile, buffer/file/dir
local, and region controls for inline code, hyphenation, token policies,
kinsoku profiles, overlong-token behavior, and buffer measure. Region
`ekp-break-policy` provides `normal`, `hyphenate`, and `no-hyphen`; the
existing `ekp-no-break` remains the only manual hard-atom owner.
- Red tests: prove inline faces no longer cause paragraph skipping; prove
region > explicit local > mode profile > global precedence; prove token
classification/downgrade behavior; prove `common`/`zh`/`ja`/`off`/custom
kinsoku; prove `emergency`/`overflow`/`natural` overlong modes; prove
`narrowest-window`, fixed, and `(max . PIXELS)` measure resolution; and
reproduce the current showcase orphan-glyph screenshot as a failing
public path before the fix, with an oracle that rejects any pathological
single-CJK source line in the crafted paragraph when a legal
non-emergency alternative exists.
- Verification: focused RED/GREEN ERT for core, buffer, command, cache, and
diagnostics; C/Elisp parity without changing the 15-argument C entry or
15-field batch payload; 300-case property fuzz; warning-as-error Elisp
compilation; C builds/tests; checkdoc/package/static/release gates; and
clean fullscreen single-window GUI evidence for inline wrapping,
no-orphan showcase text, explicit no-break, block verbatim, measure
modes, and overlong-token modes.
- Impact: `ekp.el`, `ekp-buffer.el`, `ekp_c/ekp_kp.c` only if parity logic
requires a mirrored DP adjustment, tests, bilingual user/developer
documentation, spec/plan/tech reference, issue/change records, changelog,
and a postmortem explaining the policy boundary.
- Implementation evidence recorded: G002 completed core policy compilation
without changing the 15-argument/15-field C boundary; G003 completed
buffer ownership, consult-only mode profiles, local/region controls,
diagnostics, and generation reflow; G004 completed the showcase split
between automatic inline code, explicit no-break, and verbatim block;
G005 performance work removed unconditional policy measure from paragraph
semantic identity, canonicalized adjacent equal filtered property
intervals, moved repeated policy full analysis behind a bounded two-tier
cache, and added a bounded per-paragraph semantic plan cache; G006
cleanup touched only `ekp.el` and `ekp-buffer.el`, removing redundant
policy/cache code while preserving behavior; G007 resolved the first G006
review blockers by returning consumer-owned copies of all plan-owned
mutable payloads from semantic-plan cache hits, keeping `para`
intentionally shared for append identity, and by making malformed non-nil
C single/batch results signal `ekp-backend-contract-error` while
preserving nil-result Elisp fallback and the 15-argument/15-field C
boundary; G008 resolved the final string-leaf context alias by using the
recursive context copier for cons/vector/string payloads both when the
context snapshot/cache key is created and when a plan is returned.
- GUI evidence recorded: `/tmp/ekp-g004-evidence.Tp77dW` has 12/12
checkpoints, a 25.75s/206-frame recording, no black segments, no failed
assertions, automatic inline wrapping 3→2→3 lines across 280→340→280,
every internal inline split as source whitespace, C active, exact source,
zero overlays, and policy no-hyphen→normal settling with generation_delta
1, live nil-plan span 1, stale nil-plan span 0, and current projection.
- Verified so far: G002 focused core policy ERT 22/22, no-break 5/5, cache
15/15, C parity 5/5, 300-case fuzz, warning-clean C build, byte
compilation, checkdoc, and diff-check; G003 core ERT 118/118, buffer ERT
119/119, watcher invariants 2/2, byte compilation, checkdoc, and
diff-check; G004 GUI verifier ERT 7/7 and clean GUI evidence; G005
formal four-interleaved evaluator layout parity true, core baseline
p50/p95 38.6679/51.6782ms versus candidate 21.7102/33.0040ms for
43.8549%/36.1354% gains, resize baseline p50/p95 43.3831/55.6250ms
versus candidate 22.0919/32.8202ms for 49.0773%/40.9973% gains, both
candidate p95 values under 50ms and both gain sets at least 20%; live
evaluator source-instrumented locked goal remains the known
`validation_failed` debt, with parity, zero-work, GC, conflict, and
all-width-nonregression true, current C p95/p99 26.449/26.740ms
(76.65%/77.23% improvement), current Elisp p95/p99 49.940/52.017ms
(91.64%/91.35% improvement), consistent with historical open `issue018`
and not a regression; byte-compiled production public path passed three
runs with zero GC, C append p99 1.361-1.368ms, C hard p99
1.876-1.891ms, Elisp append p99 1.692-1.775ms, and Elisp hard p99
2.100-2.230ms; default ERT 262/262, seeded permuted ERT seed 20260728
255/255, isolated per-test process suite exit 0, targeted alias guard
passed, property fuzz 300/300, warning-as-error byte compilation,
package-lint pinned at `35996f478d81e51dae4fa30d051f741895d07399` exit
0 with only an external obsolete warning from the local names dependency,
empty checkdoc, release, 49-entry dictionary manifest, pinned dictionary
update, shell syntax, CI YAML, diff-check, portable/native/debug/sanitize
warning-clean C builds, module 1.6/4-thread smoke, and focused C 19/19
including the 15-argument/15-field boundary passed; G006 targeted
cleanup verification passed 6/6 and 3/3, full core ERT 125/125 and
buffer ERT 120/120 passed, and byte compilation, diff-check, and C build
gates passed; G008 formal resize parity true, core baseline/candidate
p50/p95 36.595/48.357ms versus 23.266/36.363ms for 36.42%/24.80%
gains, resize baseline/candidate p50/p95 41.443/53.541ms versus
23.761/36.889ms for 42.67%/31.10% gains, ERT 268/268, fuzz 300/300,
release gates pass, and byte-compiled current public path records zero
GC with C append/hard p99 1.440-1.464/2.022-2.050ms and Elisp
append/hard p99 1.648-1.687/2.136-2.335ms, all below 16ms.
- Closure: task034 developer implementation, repository gates, performance
gates, cleanup gates, G007 first-blocker fixes, and the G008 final
string-leaf context fix are complete.
`issue021` is closed after user visual confirmation, historical `issue018`
remains open, and final independent review returned APPROVE with
architecture status CLEAR.
- task035 [x] Falsify line-width-scaled final-pass emergency stretch.
- Source: `issue021`, the user's 2026-08-02 narrow-showcase screenshot,
and the G009 upstream K-P review.
- Red tests: reproduce the mixed showcase paragraph at narrow widths and
reject any isolated CJK source line through the core, public buffer path,
and GUI oracle.
- Work attempted: keep the strict pass unchanged; in the final pass add a
line-width-sized finite background emergency stretch to ordinary
underfull candidates and score them through adjustment ratio, badness,
fitness, and demerits. Keep the fixed artificial emergency transition
only for truly overfull first permitted hard/atomic runs. Do not add
CJK-orphan, unit, or screenshot-specific penalties.
- Validation: focused core single-CJK ERT passes 2/2; focused core, C,
hard-atom, buffer, and GUI-oracle G009 coverage passes 18/18 in the
implementation lane; warning-as-error byte compilation and the C build
pass. Full regression then falsified the line-width stretch detail: it
made emergency stretch scale with the candidate measure instead of a TeX
fixed dimension and regressed existing emergency behavior.
- Impact: `ekp.el`, `ekp_c/ekp_kp.c`, tests, bilingual documentation,
phase records, changelog, and postmortem. Public configuration and the C
ABI are unchanged.
- Closure: task035 is closed as a disproved implementation attempt, not as
the accepted current behavior. Continue with `task036`.
- task036 [x] Implement fixed-dimension final-pass emergency stretch and
preserve the final active path.
- Source: task035 full-regression failure, `issue021`, and the G009
original K-P correction.
- Work: replace the candidate-width/line-width emergency stretch detail
with a TeX-style fixed dimension exposed as
`ekp-emergency-stretch-pixel`. A nil value auto-resolves to roughly three
display-font `M` widths; a non-negative integer fixes the stretch in
pixels. Keep the strict pass unchanged and the 15-field C paragraph ABI
stable. In the final pass, preserve the best provisional overfull path
with tight fitness and zero incremental demerits only when no normal
active path survives to that breakpoint. Do not add CJK-orphan, unit,
atom-adjacency, or screenshot-specific penalties.
- Red tests: add five focused RED cases covering fixed dimension versus
measure scaling, nil auto resolution, non-negative integer override,
Elisp/C parity without C ABI growth, and renderer width/glue consistency.
- Regression gate: the original three regressions that task035 broke must
stay green alongside the new RED cases.
- Full gate: focused G009 core/public-buffer/C parity and GUI-oracle
coverage, final fullscreen dynamic GUI evidence, default/permuted/
isolated ERT, 300-case fuzz, warning-as-error byte compilation, C builds,
checkdoc/package/static/release gates, and diff-check.
- Result: production Elisp 1D, looseness/parshape, and C implementations
match; renderer glue distribution uses the chosen line's actual rest;
explicit hard atoms forbid only interior breaks and may share an overflow
line with preceding ordinary content. The test oracle now requires a
nonempty source-covering plan so an empty result cannot false-green.
- Validation: focused root regressions pass 8/8, emergency selection passes
10/10, core/buffer/GUI oracle passes 8/8, full ERT passes 288/288, seeded
and isolated suites pass 279/279, fuzz passes 300/300, portable/native/
debug/sanitize C builds and warning-as-error production plus changed-test
byte compilation pass, checkdoc/package/static/release gates pass, formal
core/resize performance gates pass, and reviewed 42.78-second
single-window dynamic GUI evidence at
`/tmp/ekp-g009-evidence-retry.UOpPNp` returns PASS.
Final independent code review returns APPROVE and architecture review
returns CLEAR.
- Closure: developer implementation and full quality gate complete;
`issue021` closed after the user's 2026-08-02 visual confirmation.

View File

@ -8,6 +8,18 @@ project aims to follow [Semantic Versioning](https://semver.org/).
### Fixed
- Malformed non-nil C backend results no longer fall back to Elisp. Nil whole
results and nil per-item breaks remain soft failures; malformed single or
batch output now signals `ekp-backend-contract-error`, so a broken backend
cannot silently produce a different layout.
- An overwide `ekp-no-break` inline atom no longer collapses its preceding
CJK text into one-glyph emergency lines. The final pass now gives ordinary
underfull candidates finite background emergency stretch and lets normal
badness/fitness/demerits choose the layout. If an overfull candidate would
otherwise extinguish every active path, TeX-style artificial demerits
preserve the best provisional path with zero incremental cost. The rule is
content-independent and identical in the Elisp 1D, looseness/parshape, and
C engines.
- C-backed core layout and complete resize/reflow now reuse prepared
paragraph, DP, and gap geometry and avoid duplicate projection
publication. The four-round same-machine gate records p95 values of
@ -148,6 +160,12 @@ project aims to follow [Semantic Versioning](https://semver.org/).
### Internal
- Cached semantic layout plans now return consumer-owned copies of all
plan-owned mutable payloads: strings, context, boxes, offsets, line/glue/
gap records, and signatures. Layout context snapshots and returned contexts
recursively copy conses, vectors, and strings, so mutable policy inputs
cannot alias the cache key. The paragraph object remains intentionally
shared for stable append identity.
- DP reuse identity, edge-space exclusion, and the lossless marker
vocabulary now each have one directly tested rule owner. The consolidation
removes formula/property-list drift without adding modules or hot-loop

View File

@ -121,10 +121,12 @@ badness = min(10000, 100·|adjustment/flexibility|³)
```
Fitness classes (tight/decent/loose/very-loose) follow the TeX ratio
thresholds. Special cases: single-box lines use flexibility 1 and
fitness decent; the last line pays `(line-penalty + short-badness)²`
where `short-badness = last-line-short-penalty × (1 fill)` when the
fill ratio is below `ekp-last-line-min-ratio`.
thresholds. Special cases: single-box lines use flexibility 1 in the
strict pass; the final pass uses the same finite emergency stretch as
ordinary underfull lines. The last line pays
`(line-penalty + short-badness)²` where
`short-badness = last-line-short-penalty × (1 fill)` when the fill ratio
is below `ekp-last-line-min-ratio`.
Deviations from the 1981 paper, by design: penalties are always added
as `+p²` (no negative/flagged penalties), there is no `q`/looseness in
@ -135,12 +137,22 @@ the main pass (see §6), and adjacent-fitness is a flat constant.
Some inputs admit no valid layout: an unbreakable box wider than the
line, or a rigid (all-`nws`) region that cannot stretch to the target.
A strict pass runs first; if the paragraph end is unreachable, a second
pass additionally allows **emergency breaks** — single-box lines with
demerits `(line-penalty + 10000)² + rest²`, at least as bad as any
regular line. This guarantees, by induction over positions, that every
input produces output (regression: narrow CJK used to return an empty
string), while the common case pays nothing and keeps pure K-P
optimality. Both engines implement the identical strategy.
pass adds a finite background emergency stretch to ordinary underfull
candidates and still scores them through the same adjustment ratio,
badness, fitness, and demerits used by the strict pass. That keeps
underfull final-pass choices inside the global K-P DP instead of forcing
them through a separate fixed-cost path.
Separately, the final pass implements TeX's `artificial_demerits` safeguard.
When an overfull candidate would otherwise remove the last active path to a
breakpoint and no non-overfull candidate survives there, the best provisional
path is installed with tight fitness and zero incremental demerits. This is a
reachability rule, not a hard-atom scoring shortcut: it never competes while a
normal active path survives, and it does not inspect CJK, units, or token
classes. Thus every input still produces a complete plan (regression: narrow
CJK used to return an empty result), while ordinary underfull lines compete
by normal K-P cost. The Elisp 1D, looseness/parshape, and C engines implement
the identical strategy.
## 5. Rendering
@ -162,6 +174,16 @@ mechanism. `ekp-render-layout-string` consumes it for the public string API;
the buffer integration can consume the same decisions without re-running or
reinterpreting the KP algorithm.
Cached semantic plans are immutable to the cache owner. A cache hit returns
a consumer-owned copy of every plan-owned mutable payload: source string,
context tree, box vector and box strings, source offsets, line records,
line glues, gap records, and line signatures. `ekp-layout-plan-para` is the
intentional exception: paragraph-cache ownership predates the semantic-plan
cache, and append planning depends on stable paragraph identity.
Layout context snapshots and returned plan contexts use the same recursive
copier for conses, vectors, and strings, so mutable policy inputs cannot alias
the cache key or a later consumer plan.
The two consumers deliberately have different representation rights.
#### String renderer
@ -281,9 +303,33 @@ the one authoritative width.
own box; `ekp-para-breaks-allowed` forbids gaps per kinsoku (full-
and halfwidth), `ekp-no-break' spans and NBSP-family joiners.
Forbidden gaps carry no glue. The DP skips them as candidates while
the line keeps extending; the emergency fallback treats a run with
no permitted inner break as atomic. C receives the sparse
`forbidden-positions` vector.
the line keeps extending. In the final pass, legal underfull candidates
receive finite emergency stretch and are scored by normal
badness/demerits. If an overfull candidate would extinguish the last active
final-pass path, TeX-style artificial demerits preserve that path with zero
incremental cost. C receives the sparse `forbidden-positions` vector.
Explicit hard atoms forbid only breaks inside their interval; an otherwise
legal boundary immediately before or after an atom remains legal. Atom
adjacency therefore receives no special scoring or break prohibition.
- **Configurable policy compilation**: buffer and core policy variables are
resolved before tokenization into private structural intervals. Region
`ekp-break-policy` wins first, explicit buffer/file/dir locals win over
mode profiles, and profiles win over global defaults. Token policies are
the only merged category map; scalar and face-list options replace the
lower scope. The core may use private properties such as
`ekp--face-break-policy`, `ekp--no-hyphen`, and
`ekp--literal-spacing` on an analysis copy, but cache keys, plan strings,
boxes, rendered strings, and `ekp--last-para` must contain only public
source properties. Automatic face no-break is measured over the
contiguous private face-policy span and downgrades to no-hyphen when
overwide; explicit `ekp-no-break` never downgrades.
- **Literal inline spaces**: face-derived `no-hyphen` preserves source
spaces as literal boxes inside a line, forbids a break that would move a
literal source-space box to line start, and permits the complementary
break after a source-space box. When such whitespace is the selected
visual break, source ownership belongs to the break gap metadata, not to a
trailing visible line box. This rule is policy-derived and must not depend
on whether the source also carries a public `face` property.
- **Alignment** (`ekp-alignment`): non-justify modes zero the glue
stretch/shrink arrays and class params; the DP widens `max_w` by an
extra per-line stretch R (`ekp-c-set-penalties` arg 7), so badness =
@ -306,7 +352,9 @@ the one authoritative width.
C module 1.6: `ekp-c-break-with-arrays' takes 15 args
(…, forbidden-positions, tail-protrudes, hyphen-protrude,
first-line-width); batch vectors have 15 elements;
`ekp-c-set-penalties' takes 47.
`ekp-c-set-penalties' takes 48. Policy compilation feeds the existing
hyphen-position and forbidden-break vectors; it must not add a sixteenth C
argument or batch field without a new architecture decision.
Performance after the feature wave (byte-compiled + C, Apple
Silicon, batch): justify zh w=200 ≈ 54 ms, range zh ≈ 117 ms —
@ -349,19 +397,22 @@ the source of truth for all font-dependent data.
pool is created lazily on the first multi-paragraph batch and sized
to the machine's cores; a full queue blocks the submitter rather
than dropping the task.
- `ekp-c-set-penalties` (47 args): called by `ekp--c-sync-params`
- `ekp-c-set-penalties` (48 args): called by `ekp--c-sync-params`
before *every* C entry, so `ekp-line-penalty` & friends always take
effect (regression: they were never synced before).
- `ekp-c-module-load` refuses modules older than
`ekp-c-module-required-version` and falls back to Elisp, preventing
arity mismatches after upgrades.
An unavailable module, an allocation/no-result nil, or an incompatible
module version falls back to the Elisp engine. Invalid direct API input
signals `ekp-c-invalid-input`, and any signal from an enabled backend
propagates through the public formatter; the dispatcher does not catch and
hide it. The module never silently produces a different layout on partial
failure. The two engines are verified byte-identical by
An unavailable module, an incompatible module version, a nil whole C result,
or a nil per-item/break result falls back to the Elisp engine. Invalid
direct API input signals `ekp-c-invalid-input`. Any non-nil malformed
backend result signals `ekp-backend-contract-error`: malformed cons shape,
non-list breaks, non-integer/out-of-range/non-increasing/partial breaks,
nonnumeric cost, or malformed batch result shape. Any signal from an
enabled backend propagates through the public formatter; the dispatcher does
not catch and hide it. The module never silently produces a different
layout on partial failure. The two engines are verified byte-identical by
`ekp-test-c-parity-simple` / `ekp-test-c-parity-files` and the 300-case
property fuzz.

View File

@ -109,7 +109,8 @@ badness = min(10000, 100·|adjustment/flexibility|³)
```
松紧等级(tight/decent/loose/very-loose)沿用 TeX 的比例阈值。特殊情
况:单盒行 flexibility 固定为 1、fitness 为 decent;末行代价为
况:严格遍的单盒行 flexibility 固定为 1;最终遍使用与普通欠宽行相同
的有限 emergency stretch。末行代价为
`(line-penalty + 短行badness)²`,填充率低于 `ekp-last-line-min-ratio`
`短行badness = last-line-short-penalty × (1 填充率)`
@ -121,10 +122,17 @@ penalty/flagged 断点),主流程无 `q`/looseness(见 §6),相邻松紧惩
某些输入不存在合法排版:比行宽更宽的不可断盒子,或无法伸展到目标宽
的刚性(全 `nws`)区段。先跑严格遍;若段尾不可达,第二遍额外允许
**紧急断行**——demerits 为 `(line-penalty + 10000)² + rest²` 的单盒行,
不低于任何常规行的代价。由位置归纳可证:任何输入必有输出(回归:窄
栏 CJK 曾整段返回空串),常规输入不付任何代价、保持纯 K-P 最优。两个
引擎实现完全相同的策略。
为普通欠宽候选加入有限 background emergency stretch,并继续走同一套
adjustment ratio、badness、fitness 和 demerits。这样最终遍的欠宽选择
仍由全局 K-P DP 比较,不会被塞进另一条固定代价路径。
另外,最终遍实现 TeX 的 `artificial_demerits` 可达性保护。如果某个
超宽候选将使一个断点的最后活动路径消失,且该断点没有任何未超宽候选
存活,就以 tight fitness 和零增量 demerits 安装最佳暂存路径。这不是
hard atom 专用的评分捷径:只要正常活动路径还在,它就不参与竞争,也不
检查中文、单位或 token 类别。因此任何输入仍会得到覆盖完整源文本的
plan(回归:窄栏 CJK 曾整段返回空结果),而普通欠宽行仍按正常 K-P 代价
竞争。Elisp 1D、looseness/parshape 与 C 引擎实现相同语义。
## 5. 渲染
@ -143,6 +151,15 @@ penalty/flagged 断点),主流程无 `q`/looseness(见 §6),相邻松紧惩
`ekp-render-layout-string` 用它服务现有字符串 API;buffer 集成可复用
完全相同的决策,无需重新运行或重新解释 KP 算法。
缓存的 semantic plan 对缓存 owner 不可变。缓存命中会返回 consumer
自有副本,覆盖所有 plan 自有的可变 payload:源字符串、context 树、盒
向量与盒字符串、源 offset、line 记录、line glue、gap 记录和 line
signature。`ekp-layout-plan-para` 是有意例外:段落缓存所有权早于
semantic-plan cache,append planning 依赖稳定的段落身份。
layout context snapshot 与返回 plan context 使用同一个递归 copier,
覆盖 cons、vector 与 string,因此可变 policy 输入不会别名到 cache key
或后续 consumer plan。
两个消费方拥有不同且明确的表示权限。
#### 字符串渲染器
@ -242,8 +259,27 @@ idle formatter。point 移动本身严格零副作用;之后若在别处发生
- **断行许可**:每个 CJK 字符(含标点)独立成盒;
`ekp-para-breaks-allowed` 按禁则(全角与半角)、`ekp-no-break`
区间及 NBSP 族连接符禁止相应间隙,被禁间隙不携带 glue。DP 跳过
被禁候选但继续延伸行;紧急兜底把"内部无许可断点的连跑段"视为
原子。C 侧接收稀疏 `forbidden-positions` 向量。
被禁候选但继续延伸行。最终遍中的合法欠宽候选获得有限 emergency
stretch 并按正常 badness/demerits 评分;若超宽候选将消灭最后活动
路径,TeX 风格 artificial demerits 以零增量代价保留该路径。C 侧接收
稀疏 `forbidden-positions` 向量。显式 hard atom 只禁止其区间内部
断行;atom 前后本来合法的边界仍然合法。atom 邻接不获得特殊评分,
也不会额外禁止断点。
- **可配置 policy 编译**:buffer 与 core policy 变量在 tokenization
之前解析为私有结构区间。区域 `ekp-break-policy` 优先,显式
buffer/file/dir local 值优先于 mode profile,profile 优先于全局默认。
只有 token policy 是按类别合并的 map;标量与 face 列表都直接替换
低优先级值。core 可以在 analysis copy 上使用
`ekp--face-break-policy`、`ekp--no-hyphen`、`ekp--literal-spacing`
等私有属性,但 cache key、plan string、box、rendered string 与
`ekp--last-para` 只能保留公开源属性。自动 face no-break 按连续的
私有 face-policy 区间整体测宽,超宽时降级为 no-hyphen;显式
`ekp-no-break` 永不降级。
- **行内字面空格**:face 派生的 `no-hyphen` 在行内保留源空格为字面
box,禁止会把源空格 box 移到行首的断点,并允许互补的"空格后到内容"
断点。当这些空白成为选中的视觉断点时,源归属属于 break gap metadata,
而不是尾随可见行 box。该规则来自 policy,不能依赖源上是否另有公开
`face` 属性。
- **对齐**(`ekp-alignment`):非两端对齐把 glue 伸缩数组与类参数
置零,DP 给 `max_w` 加每行额外伸展 R(`ekp-c-set-penalties` 第 7
参),badness = 100·(欠宽/R)³;渲染层按模式分派剩余(尾部/对半/
@ -261,7 +297,8 @@ idle formatter。point 移动本身严格零副作用;之后若在别处发生
C 模块 1.6:`ekp-c-break-with-arrays` 15 参(…、forbidden-positions、
tail-protrudes、hyphen-protrude、first-line-width);batch 向量 15 元;
`ekp-c-set-penalties` 47 参。
`ekp-c-set-penalties` 48 参。policy 编译只进入现有断词位置与禁断
向量;没有新的架构决策时,不得增加第 16 个 C 参数或 batch 字段。
特性完成后的性能(字节编译 + C,Apple Silicon,batch):justify zh
w=200 ≈ 54 ms、range zh ≈ 117 ms——justify 与特性前持平,range 因盒
@ -296,16 +333,20 @@ C 模块(`ekp_c/`,版本 1.6)只执行阶段 ④。所有字体相关数据以 E
——每段一个任务(这是正确的并行粒度;DP 本身天然串行)。线程池在
首次多段落 batch 时惰性创建,按机器核心数定大小;队列满时提交方
阻塞等待而非丢弃任务。
- `ekp-c-set-penalties`(47 参数):`ekp--c-sync-params` 在**每次**
- `ekp-c-set-penalties`(48 参数):`ekp--c-sync-params` 在**每次**
进入 C 之前调用,保证 `ekp-line-penalty` 等变量始终生效(回归:此
前从未同步)。
- `ekp-c-module-load` 拒绝低于 `ekp-c-module-required-version` 的模块
并回落到 Elisp,避免升级后的参数数量不匹配。
模块不可用、分配/无结果返回 nil 或 ABI 版本不兼容时回落到 Elisp。
直接 API 的非法输入 signal `ekp-c-invalid-input`;已启用后端发出的
任何 signal 都会穿过公共 formatter,dispatcher 不捕获或隐藏。模块
不会在部分失败时静默产出不同的排版。两引擎输出逐字节一致,由
模块不可用、ABI 版本不兼容、整批 C 结果为 nil 或单项/断点结果为
nil 时回落到 Elisp。直接 API 的非法输入 signal
`ekp-c-invalid-input`。任何非 nil 的畸形后端结果 signal
`ekp-backend-contract-error`:结果 cons 形状错误、breaks 不是 list、
break 非整数/越界/非递增/未覆盖段尾、cost 非数字,或 batch 结果形状
错误。已启用后端发出的任何 signal 都会穿过公共 formatter,dispatcher
不捕获或隐藏。模块不会在部分失败时静默产出不同的排版。两引擎输出
逐字节一致,由
`ekp-test-c-parity-simple` / `ekp-test-c-parity-files` 及 300 例性质
fuzz 验证。

View File

@ -78,19 +78,110 @@ command and is not limited by this value."
"Seconds of work allowed in one lazy reflow tick."
:type 'number)
(defconst ekp-buffer-org-skip-faces
'(org-block org-block-begin-line org-block-end-line org-code
org-verbatim org-table org-meta-line)
(defconst ekp-buffer-org-block-faces
'(org-block org-block-begin-line org-block-end-line
org-table org-meta-line)
"Org block faces whose paragraphs stay verbatim.")
(defconst ekp-buffer-org-inline-faces
'(org-code org-verbatim)
"Org inline faces that receive automatic inline break policy.")
(defconst ekp-buffer-org-skip-faces ekp-buffer-org-block-faces
"Reasonable `ekp-buffer-skip-faces' preset for Org buffers.")
(defconst ekp-buffer-markdown-skip-faces
'(markdown-code-face markdown-inline-code-face markdown-pre-face
markdown-table-face)
(defconst ekp-buffer-markdown-block-faces
'(markdown-code-face markdown-pre-face markdown-table-face)
"Markdown block faces whose paragraphs stay verbatim.")
(defconst ekp-buffer-markdown-inline-faces
'(markdown-inline-code-face)
"Markdown inline faces that receive automatic inline break policy.")
(defconst ekp-buffer-markdown-skip-faces ekp-buffer-markdown-block-faces
"Reasonable `ekp-buffer-skip-faces' preset for Markdown buffers.")
(defun ekp-buffer--safe-symbol-list-p (value)
"Return non-nil when VALUE is a closed list of symbols."
(and (proper-list-p value) (seq-every-p #'symbolp value)))
(defun ekp-buffer--safe-measure-p (value)
"Return non-nil when VALUE is a documented measure mode."
(or (eq value 'narrowest-window)
(and (integerp value) (> value 0))
(and (consp value) (eq (car value) 'max)
(integerp (cdr value)) (> (cdr value) 0))))
(defun ekp-buffer--safe-mode-policy-entry-p (entry)
"Return non-nil when ENTRY is a closed mode profile entry."
(and (consp entry)
(symbolp (car entry))
(proper-list-p (cdr entry))
(seq-every-p
(lambda (setting)
(and (consp setting)
(pcase (car setting)
('ekp-inline-code-policy
(ekp--safe-break-policy-value-p (cdr setting)))
('ekp-hyphenation
(ekp--safe-hyphenation-value-p (cdr setting)))
('ekp-overlong-token-policy
(memq (cdr setting) '(emergency overflow natural)))
('ekp-emergency-stretch-pixel
(ekp--safe-emergency-stretch-pixel-p (cdr setting)))
('ekp-kinsoku-profile
(memq (cdr setting) '(common zh ja off custom)))
('ekp-cjk-no-line-start-extra
(stringp (cdr setting)))
('ekp-cjk-no-line-end-extra
(stringp (cdr setting)))
('ekp-number-unit-suffixes
(and (proper-list-p (cdr setting))
(seq-every-p #'stringp (cdr setting))))
('ekp-token-break-policies
(ekp--safe-token-break-policies-p (cdr setting)))
('ekp-buffer-skip-faces
(ekp-buffer--safe-symbol-list-p (cdr setting)))
('ekp-buffer-inline-faces
(ekp-buffer--safe-symbol-list-p (cdr setting)))
('ekp-buffer-measure
(ekp-buffer--safe-measure-p (cdr setting)))
(_ nil))))
(cdr entry))))
(defun ekp-buffer--safe-mode-policy-alist-p (value)
"Return non-nil when VALUE is a safe mode policy alist."
(and (proper-list-p value)
(seq-every-p #'ekp-buffer--safe-mode-policy-entry-p value)))
(defcustom ekp-buffer-skip-faces nil
"Faces whose paragraphs stay verbatim."
:type '(repeat face))
:type '(repeat face)
:safe #'ekp-buffer--safe-symbol-list-p)
(defcustom ekp-buffer-inline-faces nil
"Faces whose exact spans use `ekp-inline-code-policy'."
:type '(repeat face)
:safe #'ekp-buffer--safe-symbol-list-p)
(defcustom ekp-buffer-mode-policy-alist
`((org-mode . ((ekp-buffer-skip-faces . ,ekp-buffer-org-block-faces)
(ekp-buffer-inline-faces . ,ekp-buffer-org-inline-faces)))
(markdown-mode
. ((ekp-buffer-skip-faces . ,ekp-buffer-markdown-block-faces)
(ekp-buffer-inline-faces . ,ekp-buffer-markdown-inline-faces))))
"Mode profiles consulted by automatic and manual buffer layout."
:type '(alist :key-type symbol :value-type sexp)
:safe #'ekp-buffer--safe-mode-policy-alist-p)
(defcustom ekp-buffer-measure 'narrowest-window
"Measure used for automatic and default manual buffer layout.
`narrowest-window' uses the narrowest live window displaying the
buffer. A positive integer is a fixed pixel width. `(max . N)'
caps the narrowest live window at N pixels."
:type '(choice (const narrowest-window) (integer :tag "Fixed pixels")
(cons (const max) (integer :tag "Maximum pixels")))
:safe #'ekp-buffer--safe-measure-p)
(defvar-local ekp-buffer-skip-predicate nil
"Function called with a paragraph string that should stay verbatim.")
@ -110,6 +201,9 @@ command and is not limited by this value."
(defvar-local ekp-buffer--auto-width nil)
(defvar-local ekp-buffer--resize-timer nil)
(defvar-local ekp-buffer--composition-timer nil)
(defvar-local ekp-buffer--policy-reflow-timer nil)
(defvar-local ekp-buffer--policy-reflow-context nil)
(defvar-local ekp-buffer--policy-context-snapshot nil)
(defvar-local ekp-buffer--pending nil)
(defvar-local ekp-buffer--chunk-timer nil)
(defvar-local ekp-buffer--generation 0)
@ -155,15 +249,127 @@ command and is not limited by this value."
;;; Setup
(defconst ekp-buffer--policy-variables
'(ekp-inline-code-policy ekp-hyphenation ekp-token-break-policies
ekp-number-unit-suffixes
ekp-overlong-token-policy ekp-kinsoku-profile
ekp-emergency-stretch-pixel
ekp-cjk-no-line-start-extra ekp-cjk-no-line-end-extra
ekp-buffer-skip-faces ekp-buffer-inline-faces
ekp-buffer-mode-policy-alist ekp-buffer-measure)
"Variables that affect buffer layout policy or measure.")
(defun ekp-buffer--matching-mode-profile ()
"Return the first matching mode policy profile for the current buffer."
(seq-some
(lambda (entry)
(and (derived-mode-p (car entry))
(cdr entry)))
ekp-buffer-mode-policy-alist))
(defun ekp-buffer--profile-value (profile variable)
"Return VARIABLE's value from PROFILE, or nil if absent."
(assq variable profile))
(defun ekp-buffer--effective-scalar (profile variable)
"Return VARIABLE for PROFILE after local, mode-profile, then global precedence."
(if (local-variable-p variable)
(symbol-value variable)
(let ((current (symbol-value variable))
(default (default-value variable))
(profile-value (ekp-buffer--profile-value profile variable)))
(cond
((not (equal current default)) current)
(profile-value (cdr profile-value))
(t default)))))
(defun ekp-buffer--merge-token-policies (profile)
"Return effective token policies after merging global, PROFILE, and local."
(let ((merged (copy-tree (default-value 'ekp-token-break-policies)))
(current ekp-token-break-policies)
(default (default-value 'ekp-token-break-policies)))
(dolist (entry (cdr (ekp-buffer--profile-value
profile 'ekp-token-break-policies)))
(setf (alist-get (car entry) merged) (cdr entry)))
(when (or (local-variable-p 'ekp-token-break-policies)
(not (equal current default)))
(dolist (entry current)
(setf (alist-get (car entry) merged) (cdr entry))))
merged))
(defun ekp-buffer--policy-context (&optional width)
"Return the effective policy context for the current buffer at WIDTH."
(let* ((profile (ekp-buffer--matching-mode-profile))
(emergency-stretch
(ekp-buffer--effective-scalar
profile 'ekp-emergency-stretch-pixel))
(resolved-emergency-stretch
(let ((ekp-emergency-stretch-pixel emergency-stretch))
(ekp--resolved-emergency-stretch-pixel))))
(ekp--copy-layout-context-value
(list :inline-code-policy
(ekp-buffer--effective-scalar profile 'ekp-inline-code-policy)
:hyphenation
(ekp-buffer--effective-scalar profile 'ekp-hyphenation)
:token-break-policies (ekp-buffer--merge-token-policies profile)
:number-unit-suffixes
(ekp-buffer--effective-scalar profile 'ekp-number-unit-suffixes)
:overlong-token-policy
(ekp-buffer--effective-scalar profile 'ekp-overlong-token-policy)
:emergency-stretch-pixel resolved-emergency-stretch
:kinsoku-profile
(ekp-buffer--effective-scalar profile 'ekp-kinsoku-profile)
:cjk-no-line-start-extra
(ekp-buffer--effective-scalar
profile 'ekp-cjk-no-line-start-extra)
:cjk-no-line-end-extra
(ekp-buffer--effective-scalar profile 'ekp-cjk-no-line-end-extra)
:block-faces
(ekp-buffer--effective-scalar profile 'ekp-buffer-skip-faces)
:inline-faces
(ekp-buffer--effective-scalar profile 'ekp-buffer-inline-faces)
:measure
(ekp-buffer--effective-scalar profile 'ekp-buffer-measure)
:width width))))
(defmacro ekp-buffer--with-policy-context (context &rest body)
"Run BODY with CONTEXT bound as EKP's core policy environment."
(declare (indent 1))
`(let ((ekp-inline-code-policy
(plist-get ,context :inline-code-policy))
(ekp-hyphenation (plist-get ,context :hyphenation))
(ekp-token-break-policies
(plist-get ,context :token-break-policies))
(ekp-number-unit-suffixes
(plist-get ,context :number-unit-suffixes))
(ekp-overlong-token-policy
(plist-get ,context :overlong-token-policy))
(ekp-emergency-stretch-pixel
(plist-get ,context :emergency-stretch-pixel))
(ekp-kinsoku-profile (plist-get ,context :kinsoku-profile))
(ekp-cjk-no-line-start-extra
(plist-get ,context :cjk-no-line-start-extra))
(ekp-cjk-no-line-end-extra
(plist-get ,context :cjk-no-line-end-extra)))
,@body))
(defun ekp-buffer--face-member-p (face faces)
"Return non-nil when FACE intersects FACES."
(if (listp face)
(seq-intersection face faces)
(memq face faces)))
;;;###autoload
(defun ekp-org-setup ()
"Protect common Org structural faces in the current buffer."
(setq-local ekp-buffer-skip-faces ekp-buffer-org-skip-faces))
(setq-local ekp-buffer-skip-faces ekp-buffer-org-block-faces)
(setq-local ekp-buffer-inline-faces ekp-buffer-org-inline-faces))
;;;###autoload
(defun ekp-markdown-setup ()
"Protect common Markdown code faces in the current buffer."
(setq-local ekp-buffer-skip-faces ekp-buffer-markdown-skip-faces))
(setq-local ekp-buffer-skip-faces ekp-buffer-markdown-block-faces)
(setq-local ekp-buffer-inline-faces ekp-buffer-markdown-inline-faces))
;;; Width
@ -208,40 +414,66 @@ command and is not limited by this value."
(point))))
(defun ekp-buffer--effective-width (&optional buffer)
"Return the authoritative width for BUFFER.
The narrowest live window wins because text properties are buffer-wide."
(let ((windows (get-buffer-window-list
(or buffer (current-buffer)) nil t)))
(if windows
(apply #'min (mapcar #'ekp-buffer--window-pixel windows))
(ekp-buffer--window-pixel))))
"Return BUFFER's effective `ekp-buffer-measure' width.
The default uses the narrowest live window; fixed widths and `(max . N)'
come from the active buffer policy."
(with-current-buffer (or buffer (current-buffer))
(let* ((windows (get-buffer-window-list (current-buffer) nil t))
(narrowest (if windows
(apply #'min
(mapcar #'ekp-buffer--window-pixel windows))
(ekp-buffer--window-pixel)))
(measure (plist-get (ekp-buffer--policy-context) :measure)))
(cond
((eq measure 'narrowest-window) narrowest)
((and (integerp measure) (> measure 0)) measure)
((and (consp measure) (eq (car measure) 'max)
(integerp (cdr measure)) (> (cdr measure) 0))
(min narrowest (cdr measure)))
(t (error "Invalid ekp-buffer-measure: %S" measure))))))
(defun ekp-buffer--measure-report (&optional buffer)
"Return measure diagnostics for BUFFER."
(with-current-buffer (or buffer (current-buffer))
(let* ((windows (get-buffer-window-list (current-buffer) nil t))
(narrowest (if windows
(apply #'min
(mapcar #'ekp-buffer--window-pixel windows))
(ekp-buffer--window-pixel)))
(context (ekp-buffer--policy-context))
(requested (plist-get context :measure))
(effective (ekp-buffer--effective-width)))
(list :requested requested
:narrowest narrowest
:effective effective
:overflow-risk (and (integerp effective)
(> effective narrowest))))))
;;; Paragraphs and conflicts
(defun ekp-buffer--face-hit-p (string)
"Return non-nil when STRING carries a configured skip face."
(defun ekp-buffer--face-hit-p (string faces)
"Return non-nil when STRING carries one of FACES."
(let ((pos 0) (length (length string)) hit)
(while (and (< pos length) (not hit))
(let ((face (get-text-property pos 'face string)))
(setq hit (if (listp face)
(seq-intersection face ekp-buffer-skip-faces)
(memq face ekp-buffer-skip-faces)))
(setq hit (ekp-buffer--face-member-p face faces))
(setq pos (or (next-single-property-change
pos 'face string length)
length))))
hit))
(defun ekp-buffer--skip-paragraph-p (paragraph)
"Return non-nil when PARAGRAPH must stay verbatim."
(or (string-blank-p paragraph)
(text-property-not-all 0 (length paragraph)
'ekp-verbatim nil paragraph)
(text-property-not-all 0 (length paragraph) 'field nil paragraph)
(text-property-not-all 0 (length paragraph) 'read-only nil paragraph)
(and ekp-buffer-skip-faces
(ekp-buffer--face-hit-p paragraph))
(and ekp-buffer-skip-predicate
(funcall ekp-buffer-skip-predicate paragraph))))
(defun ekp-buffer--skip-paragraph-p (paragraph &optional context)
"Return non-nil when PARAGRAPH must stay verbatim under CONTEXT."
(let* ((context (or context (ekp-buffer--policy-context)))
(faces (plist-get context :block-faces)))
(or (string-blank-p paragraph)
(text-property-not-all 0 (length paragraph)
'ekp-verbatim nil paragraph)
(text-property-not-all 0 (length paragraph) 'field nil paragraph)
(text-property-not-all 0 (length paragraph) 'read-only nil paragraph)
(and faces (ekp-buffer--face-hit-p paragraph faces))
(and ekp-buffer-skip-predicate
(funcall ekp-buffer-skip-predicate paragraph)))))
(defun ekp-buffer--foreign-property-at-p (position property)
"Return non-nil when PROPERTY at POSITION is not owned by EKP."
@ -608,17 +840,53 @@ EFFECTIVE-END includes the source boundary owned by the line."
(goto-char end)))))
(nreverse ranges)))
(defun ekp-buffer--annotate-inline-faces (text context)
"Annotate inline face spans on TEXT using CONTEXT."
(let ((faces (plist-get context :inline-faces))
(policy (plist-get context :inline-code-policy))
(pos 0)
(length (length text))
annotated)
(when (and faces (not (eq policy 'normal)))
(while (< pos length)
(let* ((face (get-text-property pos 'face text))
(next (or (next-single-property-change pos 'face text length)
length)))
(when (ekp-buffer--face-member-p face faces)
(unless annotated
(setq annotated (copy-sequence text)))
(put-text-property
pos next 'ekp--face-break-policy policy annotated))
(setq pos next))))
(or annotated text)))
(defun ekp-buffer--planning-text (text context)
"Return TEXT annotated with transient inline policy from CONTEXT."
(ekp-buffer--annotate-inline-faces text context))
(defun ekp-buffer--layout-plan (text pixel context)
"Return layout plan for TEXT at PIXEL under CONTEXT."
(let ((planning-text (ekp-buffer--planning-text text context)))
(ekp-buffer--with-policy-context context
(let ((plan (ekp-layout-plan planning-text pixel)))
(setf (ekp-layout-plan-string plan) text)
plan))))
(defun ekp-buffer--layout-paragraph (beg end pixel)
"Project one hard paragraph from BEG to END at PIXEL."
(if (and ekp-buffer--automatic-pass
(> (- end beg) ekp-auto-justify-paragraph-limit))
(ekp-buffer--record-conflict
beg end "paragraph exceeds the automatic paragraph limit")
(let ((paragraph (buffer-substring beg end)))
(unless (ekp-buffer--skip-paragraph-p paragraph)
(let ((plan (ekp-layout-plan paragraph pixel)))
(when (ekp-buffer--projectable-p plan beg beg end)
(ekp-buffer--install-plan beg end pixel plan)))))))
(let* ((paragraph (buffer-substring beg end))
(context (ekp-buffer--policy-context pixel)))
(unless (ekp-buffer--skip-paragraph-p paragraph context)
(let ((plan (ekp-buffer--layout-plan paragraph pixel context)))
(if (eq (ekp-layout-plan-state plan) 'natural)
(ekp-buffer--record-conflict
beg end "overlong-token-natural")
(when (ekp-buffer--projectable-p plan beg beg end)
(ekp-buffer--install-plan beg end pixel plan))))))))
(defun ekp-buffer--dwim-bounds ()
"Return active region bounds or the hard paragraph at point."
@ -636,13 +904,16 @@ EFFECTIVE-END includes the source boundary owned by the line."
(list start finish
(and current-prefix-arg
(prefix-numeric-value current-prefix-arg))))))
(setq pixel (or pixel (ekp-buffer--window-pixel)))
(setq pixel (or pixel (ekp-buffer--effective-width)))
(let ((start (min beg end))
(finish (max beg end)))
(setq beg start
end finish))
(when (and font-lock-mode
(or ekp-buffer-skip-faces ekp-buffer-skip-predicate))
(let ((context (ekp-buffer--policy-context pixel)))
(or (plist-get context :block-faces)
(plist-get context :inline-faces)
ekp-buffer-skip-predicate)))
(font-lock-ensure beg end))
(let ((point-before (point))
(mark-before (and (mark t) (copy-marker (mark t))))
@ -759,20 +1030,76 @@ EFFECTIVE-END includes the source boundary owned by the line."
;;; Protection commands
(defvar ekp-break-policy nil
"Text property controlling regional break policy.
`normal' clears automatic token or face restrictions, `hyphenate'
enables discretionary hyphenation, and `no-hyphen' suppresses it.
This property is not a hard no-break switch; rigid atoms remain
owned by the separate `ekp-no-break' text property.")
(defun ekp-buffer--interactive-protection-args ()
"Return region arguments for an interactive protection command."
(barf-if-buffer-read-only)
(list (region-beginning) (region-end) t))
(defun ekp-buffer--after-region-policy-mutation (beg end)
"Refresh projections between BEG and END after a region policy mutation."
(if ekp-auto-justify-mode
(ekp-buffer--reflow-for-policy-change (current-buffer) t)
(ekp-buffer--clear-projection (min beg end) (max beg end))
(unless ekp-buffer--spans
(ekp-buffer--remove-integrations))))
(defun ekp-buffer--set-protection (beg end property enabled label announce)
"Set PROPERTY to ENABLED from BEG to END and optionally ANNOUNCE LABEL."
(if enabled
(add-text-properties beg end (list property t))
(remove-text-properties beg end (list property nil)))
(let ((ekp-buffer--inhibit t))
(if enabled
(add-text-properties beg end (list property t))
(remove-text-properties beg end (list property nil))))
(ekp-buffer--after-region-policy-mutation beg end)
(when announce
(message "EKP: %s on %d characters; current buffer session only"
label (- end beg))))
(defun ekp-buffer--set-break-policy (beg end policy label announce)
"Set `ekp-break-policy' POLICY from BEG to END."
(let ((ekp-buffer--inhibit t))
(if policy
(put-text-property beg end 'ekp-break-policy policy)
(remove-text-properties beg end '(ekp-break-policy nil))))
(ekp-buffer--after-region-policy-mutation beg end)
(when announce
(message "EKP: %s on %d characters; current buffer session only"
label (- end beg))))
;;;###autoload
(defun ekp-normal-break-region (beg end &optional announce)
"Mark BEG through END as ordinary break policy; ANNOUNCE reports it."
(interactive (ekp-buffer--interactive-protection-args))
(ekp-buffer--set-break-policy
beg end 'normal "Marked normal break policy" announce))
;;;###autoload
(defun ekp-enable-hyphenation-region (beg end &optional announce)
"Enable discretionary hyphenation in BEG through END; ANNOUNCE reports it."
(interactive (ekp-buffer--interactive-protection-args))
(ekp-buffer--set-break-policy
beg end 'hyphenate "Enabled hyphenation" announce))
;;;###autoload
(defun ekp-disable-hyphenation-region (beg end &optional announce)
"Disable discretionary hyphenation in BEG through END; ANNOUNCE reports it."
(interactive (ekp-buffer--interactive-protection-args))
(ekp-buffer--set-break-policy
beg end 'no-hyphen "Disabled hyphenation" announce))
;;;###autoload
(defun ekp-clear-break-policy-region (beg end &optional announce)
"Clear `ekp-break-policy' from BEG through END; ANNOUNCE reports it."
(interactive (ekp-buffer--interactive-protection-args))
(ekp-buffer--set-break-policy
beg end nil "Cleared break policy" announce))
;;;###autoload
(defun ekp-no-break-region (beg end &optional announce)
"Mark BEG through END as an unbreakable session-local atom.
@ -990,10 +1317,12 @@ LINE-END lets insertion at the hard-line end belong to its last span."
(defun ekp-buffer--live-marker-snapshot (base)
"Return live marker offsets relative to BASE."
(mapcar
(delq nil
(mapcar
(lambda (marker)
(cons marker (- (marker-position marker) base)))
(ekp-buffer--live-state-owned-markers)))
(when-let* ((position (marker-position marker)))
(cons marker (- position base))))
(ekp-buffer--live-state-owned-markers))))
(defun ekp-buffer--start-live-edit (beg end bounds)
"Start a stable edit transaction for BEG through END in BOUNDS."
@ -1098,21 +1427,26 @@ LINE-END lets insertion at the hard-line end belong to its last span."
(buffer-substring dirty-beg dirty-end)
(substring source finish))))
(defun ekp-buffer--live-context (width)
"Return non-text layout context for a live plan at WIDTH."
(list (ekp--dp-key width)
(copy-tree (ekp--width-context))
(mapcar (lambda (attribute)
(face-attribute 'default attribute nil t))
'(:family :height :width :weight :slant))
ekp-latin-lang
ekp-alignment
ekp-ragged-stretch-pixel
(and ekp-protrusion (copy-tree ekp-protrusion-ratios))
(copy-tree ekp-parshape)
ekp-first-line-indent
ekp-cjk-no-line-start-extra
(ekp--spacing-signature)))
(defun ekp-buffer--live-context (width &optional context)
"Return non-text layout context for a live plan at WIDTH.
Optional CONTEXT supplies a precomputed policy context."
(let ((context (or context (ekp-buffer--policy-context width))))
(ekp-buffer--with-policy-context context
(ekp--copy-layout-context-value
(list (ekp--dp-key width)
(ekp--width-context)
(mapcar (lambda (attribute)
(face-attribute 'default attribute nil t))
'(:family :height :width :weight :slant))
ekp-latin-lang
ekp-alignment
ekp-ragged-stretch-pixel
(and ekp-protrusion ekp-protrusion-ratios)
ekp-parshape
ekp-first-line-indent
context
(ekp--policy-signature)
(ekp--spacing-signature))))))
(defun ekp-buffer--live-key-equal-p (left right)
"Return non-nil when live cache keys LEFT and RIGHT are equivalent."
@ -1151,16 +1485,26 @@ LINE-END lets insertion at the hard-line end belong to its last span."
ekp-buffer--live-state))))
(when (and old-key old-plan
(equal (cadr key) (cadr old-key)))
(ekp-layout-plan-append old-plan text width))))
(let* ((context (ekp-buffer--policy-context width))
(planning-text (ekp-buffer--planning-text text context))
(plan (ekp-buffer--with-policy-context context
(ekp-layout-plan-append
old-plan planning-text width))))
(when plan
(setf (ekp-layout-plan-string plan) text))
plan))))
(defun ekp-buffer--live-plan-entry (text width)
"Return the cache entry for TEXT at WIDTH."
(let ((key (list text (ekp-buffer--live-context width))))
(let* ((context (ekp-buffer--policy-context width))
(key (list (copy-sequence text)
(ekp-buffer--live-context width context))))
(cons key
(or (ekp-buffer--live-cache-get key)
(ekp-buffer--live-cache-put
key (or (ekp-buffer--live-append-plan key text width)
(ekp-layout-plan text width)))))))
(ekp-buffer--layout-plan
text width context)))))))
(defun ekp-buffer--single-line-live-p (text width)
"Return non-nil if TEXT is conservatively known to fit WIDTH."
@ -1401,34 +1745,40 @@ When COMPLETE is non-nil, project every semantic line."
(beg end width text boundary complete)
"Publish TEXT's stable prefix from BEG to END at WIDTH.
BOUNDARY identifies the native row left natural unless COMPLETE is non-nil."
(cond
((or (>= beg end) (ekp-buffer--skip-paragraph-p text))
(ekp-buffer--commit-natural-live-state beg end width text))
((> (- end beg) ekp-auto-justify-paragraph-limit)
(ekp-buffer--record-conflict
beg end "paragraph exceeds the automatic paragraph limit")
(ekp-buffer--commit-natural-live-state beg end width text))
((and (not complete)
(ekp-buffer--single-line-live-p text width))
(ekp-buffer--commit-natural-live-state beg end width text))
((ekp-buffer--unsupported-live-text-p text)
(ekp-buffer--record-conflict
beg end "unsupported whitespace shrink")
(ekp-buffer--commit-natural-live-state beg end width text))
((ekp-buffer--foreign-property beg end)
(ekp-buffer--record-conflict beg end "foreign property in live line")
(ekp-buffer--commit-natural-live-state beg end width text))
(t
(let* ((entry (ekp-buffer--live-plan-entry text width))
(key (car entry))
(plan (cdr entry)))
(if (ekp-buffer--unsupported-gap plan beg)
(progn
(ekp-buffer--record-conflict
beg end "unsupported live whitespace shrink")
(ekp-buffer--commit-natural-live-state beg end width text))
(ekp-buffer--project-live-plan
key plan beg end width boundary complete))))))
(let ((context (ekp-buffer--policy-context width)))
(cond
((or (>= beg end) (ekp-buffer--skip-paragraph-p text context))
(ekp-buffer--commit-natural-live-state beg end width text))
((> (- end beg) ekp-auto-justify-paragraph-limit)
(ekp-buffer--record-conflict
beg end "paragraph exceeds the automatic paragraph limit")
(ekp-buffer--commit-natural-live-state beg end width text))
((and (not complete)
(ekp-buffer--single-line-live-p text width))
(ekp-buffer--commit-natural-live-state beg end width text))
((ekp-buffer--unsupported-live-text-p text)
(ekp-buffer--record-conflict
beg end "unsupported whitespace shrink")
(ekp-buffer--commit-natural-live-state beg end width text))
((ekp-buffer--foreign-property beg end)
(ekp-buffer--record-conflict beg end "foreign property in live line")
(ekp-buffer--commit-natural-live-state beg end width text))
(t
(let* ((entry (ekp-buffer--live-plan-entry text width))
(key (car entry))
(plan (cdr entry)))
(cond
((eq (ekp-layout-plan-state plan) 'natural)
(ekp-buffer--record-conflict beg end "overlong-token-natural")
(ekp-buffer--commit-natural-live-state beg end width text))
((ekp-buffer--unsupported-gap plan beg)
(progn
(ekp-buffer--record-conflict
beg end "unsupported live whitespace shrink")
(ekp-buffer--commit-natural-live-state beg end width text)))
(t
(ekp-buffer--project-live-plan
key plan beg end width boundary complete))))))))
(defun ekp-buffer--publish-live-prefix (&optional boundary complete)
"Publish one stable live plan through BOUNDARY.
@ -1844,6 +2194,8 @@ ACTIVE-BEG through ACTIVE-END remains owned by the refreshed live state."
(point-min) (point-max) width active-beg active-end)
(ekp-buffer--lazy-reflow
width active-beg active-end)))))
(setq ekp-buffer--policy-context-snapshot
(copy-tree (ekp-buffer--policy-context)))
(ekp-buffer--refresh-live-prefix boundary complete))))))
(defun ekp-buffer--run-scheduled-reflow (buffer)
@ -1868,6 +2220,83 @@ ACTIVE-BEG through ACTIVE-END remains owned by the refreshed live state."
#'ekp-buffer--run-scheduled-reflow
(current-buffer)))))))
(defun ekp-buffer--reflow-for-policy-change (buffer &optional force)
"Bump BUFFER generation and reflow once after a policy change.
When FORCE is non-nil, reflow even if the global policy context is
unchanged; region text properties are paragraph policy too."
(when (buffer-live-p buffer)
(with-current-buffer buffer
(when ekp-auto-justify-mode
(when (timerp ekp-buffer--policy-reflow-timer)
(cancel-timer ekp-buffer--policy-reflow-timer))
(let ((before-context ekp-buffer--policy-reflow-context)
(after-context (ekp-buffer--policy-context)))
(setq-local ekp-buffer--policy-reflow-timer nil)
(setq-local ekp-buffer--policy-reflow-context nil)
(if (and (not force) (equal before-context after-context))
(setq-local ekp-buffer--policy-context-snapshot
(copy-tree after-context))
(let ((point-before (point))
(mark-before (and (mark t) (copy-marker (mark t))))
(mark-active-before mark-active)
(modified-before (buffer-modified-p))
(undo-before buffer-undo-list))
(unwind-protect
(progn
(cl-incf ekp-buffer--generation)
(setq ekp-buffer--live-plan-cache nil)
(ekp-buffer--reflow
buffer (ekp-buffer--effective-width buffer))
(goto-char (min point-before (point-max)))
(when mark-before
(set-marker (mark-marker)
(marker-position mark-before)))
(setq mark-active mark-active-before)
(set-buffer-modified-p modified-before)
(setq buffer-undo-list undo-before))
(when mark-before (set-marker mark-before nil))))))))))
(defun ekp-buffer--schedule-policy-reflow (buffer before-context)
"Schedule one post-set policy reflow for BUFFER from BEFORE-CONTEXT."
(when (buffer-live-p buffer)
(with-current-buffer buffer
(when ekp-auto-justify-mode
(unless (timerp ekp-buffer--policy-reflow-timer)
(setq-local ekp-buffer--policy-reflow-context
(copy-tree before-context)))
(when (timerp ekp-buffer--policy-reflow-timer)
(cancel-timer ekp-buffer--policy-reflow-timer))
(setq-local
ekp-buffer--policy-reflow-timer
(run-with-timer
0 nil #'ekp-buffer--reflow-for-policy-change buffer))))))
(defun ekp-buffer--watched-raw-noop-p (symbol new-value where)
"Return non-nil when setting SYMBOL to NEW-VALUE in WHERE is a raw no-op."
(if (bufferp where)
nil
(equal (default-value symbol) new-value)))
(defun ekp-buffer--policy-variable-changed (symbol new-value operation where)
"Schedule reflow after SYMBOL is set to NEW-VALUE by OPERATION in WHERE."
(when (memq operation '(set makunbound))
(unless (ekp-buffer--watched-raw-noop-p symbol new-value where)
(if (bufferp where)
(with-current-buffer where
(ekp-buffer--schedule-policy-reflow
where (or ekp-buffer--policy-context-snapshot
(ekp-buffer--policy-context))))
(dolist (buffer (buffer-list))
(with-current-buffer buffer
(ekp-buffer--schedule-policy-reflow
buffer (or ekp-buffer--policy-context-snapshot
(ekp-buffer--policy-context)))))))))
(dolist (variable ekp-buffer--policy-variables)
(unless (memq #'ekp-buffer--policy-variable-changed
(get-variable-watchers variable))
(add-variable-watcher variable #'ekp-buffer--policy-variable-changed)))
(defun ekp-buffer--on-resize (window-or-frame)
"Schedule reflow for the buffer shown by WINDOW-OR-FRAME."
(let ((window (cond
@ -1947,9 +2376,9 @@ ACTIVE-BEG through ACTIVE-END remains owned by the refreshed live state."
(ekp-buffer--release-live-state)
(pcase-let ((`(,beg . ,end)
(ekp-buffer--para-bounds (cons (point) (point)))))
(ekp-justify-region
beg end (or ekp-buffer--auto-width
(ekp-buffer--window-pixel)))))
(ekp-justify-region
beg end (or ekp-buffer--auto-width
(ekp-buffer--effective-width)))))
(defvar-keymap ekp-auto-justify-mode-map
:doc "Keymap for `ekp-auto-justify-mode'."
@ -1963,6 +2392,11 @@ ACTIVE-BEG through ACTIVE-END remains owned by the refreshed live state."
["Justify Buffer" ekp-justify-buffer t]
["Unjustify Buffer" ekp-unjustify-buffer t]
"--"
["Mark Region Normal Break" ekp-normal-break-region (use-region-p)]
["Enable Region Hyphenation" ekp-enable-hyphenation-region (use-region-p)]
["Disable Region Hyphenation" ekp-disable-hyphenation-region (use-region-p)]
["Clear Region Break Policy" ekp-clear-break-policy-region (use-region-p)]
"--"
["Mark Region No-Break" ekp-no-break-region (use-region-p)]
["Clear No-Break Region" ekp-allow-break-region (use-region-p)]
["Mark Region Verbatim" ekp-verbatim-region (use-region-p)]
@ -1993,10 +2427,6 @@ ACTIVE-BEG through ACTIVE-END remains owned by the refreshed live state."
(let (enabled)
(unwind-protect
(progn
(unless (or ekp-buffer-skip-faces ekp-buffer-skip-predicate)
(cond
((derived-mode-p 'org-mode) (ekp-org-setup))
((derived-mode-p 'markdown-mode) (ekp-markdown-setup))))
(ekp-buffer--enable-native-wrap)
(setq ekp-buffer--auto-width (ekp-buffer--effective-width))
(ekp-buffer--reflow (current-buffer) ekp-buffer--auto-width)
@ -2010,7 +2440,8 @@ ACTIVE-BEG through ACTIVE-END remains owned by the refreshed live state."
;;;###autoload
(define-minor-mode ekp-auto-justify-mode
"Maintain a non-mutating KP display projection.
Completed paragraphs use the authoritative narrowest-window width.
Completed paragraphs use the effective `ekp-buffer-measure': narrowest
window by default, a fixed width, or a `(max . N)' cap.
Manual no-break and verbatim properties last only for the current buffer
session."
:lighter " EKP"
@ -2025,11 +2456,16 @@ session."
(cancel-timer ekp-buffer--resize-timer))
(when (timerp ekp-buffer--composition-timer)
(cancel-timer ekp-buffer--composition-timer))
(when (timerp ekp-buffer--policy-reflow-timer)
(cancel-timer ekp-buffer--policy-reflow-timer))
(ekp-buffer--cancel-pending)
(ekp-buffer--release-live-edit)
(ekp-buffer--release-live-state)
(setq ekp-buffer--resize-timer nil
ekp-buffer--composition-timer nil
ekp-buffer--policy-reflow-timer nil
ekp-buffer--policy-reflow-context nil
ekp-buffer--policy-context-snapshot nil
ekp-buffer--auto-width nil
ekp-buffer--live-plan-cache nil)
(ekp-buffer--clear-all)
@ -2042,12 +2478,27 @@ session."
(defun ekp-diagnose ()
"Report the authoritative width and any skipped projection conflicts."
(interactive)
(let ((width (ekp-buffer--effective-width))
(conflicts (length ekp-buffer--conflicts)))
(let* ((measure (ekp-buffer--measure-report))
(width (plist-get measure :effective))
(conflicts (length ekp-buffer--conflicts))
(policy (ekp-buffer--policy-context width)))
(message
"EKP: authoritative width %dpx (narrowest window), %d conflict%s"
width conflicts (if (= conflicts 1) "" "s"))
(list :width width :conflicts ekp-buffer--conflicts)))
(concat "EKP: requested %S, narrowest %d, effective %d, "
"overflow risk %s, %d conflict%s; "
"inline-code %S, hyphenation %S, kinsoku %S, overlong %S")
(plist-get measure :requested)
(plist-get measure :narrowest)
width
(if (plist-get measure :overflow-risk) "yes" "no")
conflicts (if (= conflicts 1) "" "s")
(plist-get policy :inline-code-policy)
(plist-get policy :hyphenation)
(plist-get policy :kinsoku-profile)
(plist-get policy :overlong-token-policy))
(append measure
(list :width width
:policy policy
:conflicts ekp-buffer--conflicts))))
(provide 'ekp-buffer)

View File

@ -202,7 +202,10 @@ Rules:
(setq boxes (cons spaces boxes)))
;; Inside a no-break span: spacing is literal, glue would
;; stretch — preserve the run as a rigid space box.
((text-property-not-all 0 (length spaces) 'ekp-no-break nil spaces)
((or (text-property-not-all
0 (length spaces) 'ekp-no-break nil spaces)
(text-property-not-all
0 (length spaces) 'ekp--literal-spacing nil spaces))
(setq boxes (cons spaces boxes)))
;; Latin-Latin with multiple spaces: preserve all but last
((> (length spaces) 1)

1358
ekp.el

File diff suppressed because it is too large Load Diff

View File

@ -67,7 +67,8 @@ metacharacters in the checkout path are not interpreted.
;; Synced automatically by ekp.el before every call:
(ekp-c-set-penalties LINE HYPHEN FITNESS LAST-RATIO
&optional CONSEC-HYPHEN LAST-SHORT EXTRA-STRETCH)
&optional CONSEC-HYPHEN LAST-SHORT EXTRA-STRETCH
EMERGENCY-STRETCH)
;; Single paragraph (15 args):
(ekp-c-break-with-arrays IDEAL-PREFIX MIN-PREFIX MAX-PREFIX
@ -93,9 +94,16 @@ when no indent is active).
The DP uses the same two-pass strategy as the Elisp engine: a strict
Knuth-Plass pass, then — only when the paragraph end is unreachable —
a second pass permitting emergency single-box breaks, so overlong
unbreakable tokens can never make the result empty. Badness saturates
at 10000 exactly like the Elisp side.
a final pass that adds finite background emergency stretch to ordinary
underfull candidates and still scores them through the same
badness/fitness/demerits path. Separately, if an overfull candidate would
extinguish the final active path to a breakpoint and no non-overfull
candidate survives there, TeX-style artificial demerits install the best
provisional path with tight fitness and zero incremental cost. This is a
content-independent reachability safeguard, not a hard-atom scoring rule.
Thus an overlong unbreakable token cannot make the result empty, while
ordinary underfull choices remain global K-P decisions. Badness saturates at
10000 exactly like the Elisp side.
Failure behavior: the full schema is checked before extraction. Malformed
direct API arguments signal `ekp-c-invalid-input`; allocation failure or an

View File

@ -183,6 +183,7 @@ typedef struct {
int32_t consecutive;
double last_short;
int32_t extra_stretch;
int32_t emergency_stretch;
} penalty_config_t;
static const char *parse_penalties(emacs_env *env, ptrdiff_t nargs,
@ -192,7 +193,8 @@ static const char *parse_penalties(emacs_env *env, ptrdiff_t nargs,
!i32_value_p(env, args[2]) || !finite_number_p(env, args[3]) ||
(nargs > 4 && !i32_value_p(env, args[4])) ||
(nargs > 5 && !finite_number_p(env, args[5])) ||
(nargs > 6 && !i32_value_p(env, args[6])))
(nargs > 6 && !i32_value_p(env, args[6])) ||
(nargs > 7 && !i32_value_p(env, args[7])))
return "EKP C penalties require finite signed 32-bit numbers";
out->line = clamp32(env->extract_integer(env, args[0]));
@ -209,9 +211,12 @@ static const char *parse_penalties(emacs_env *env, ptrdiff_t nargs,
: (double)env->extract_integer(env, args[5]);
out->extra_stretch = nargs > 6
? clamp32(env->extract_integer(env, args[6])) : 0;
out->emergency_stretch = nargs > 7
? clamp32(env->extract_integer(env, args[7])) : 0;
if (out->last_ratio < 0.0 || out->last_ratio > 1.0 ||
out->last_short < 0.0 || out->extra_stretch < 0)
out->last_short < 0.0 || out->extra_stretch < 0 ||
out->emergency_stretch < 0)
return "EKP C ratios and stretch values are outside valid ranges";
return NULL;
}
@ -239,6 +244,7 @@ static emacs_value Fekp_c_set_penalties(emacs_env *env, ptrdiff_t nargs,
ekp_global->consec_hyphen_penalty = config.consecutive;
ekp_global->last_line_short_penalty = config.last_short;
ekp_global->extra_stretch = config.extra_stretch;
ekp_global->emergency_stretch = config.emergency_stretch;
return env->intern(env, "t");
}
@ -758,7 +764,7 @@ int emacs_module_init(struct emacs_runtime *runtime)
defun(env, "ekp-c-cleanup", 0, 0, Fekp_c_cleanup,
"Cleanup EKP C module resources.");
defun(env, "ekp-c-set-penalties", 4, 7, Fekp_c_set_penalties,
defun(env, "ekp-c-set-penalties", 4, 8, Fekp_c_set_penalties,
"Set Knuth-Plass algorithm penalties.\n\n\
LINE-PENALTY: base penalty per line break (default 10)\n\
HYPHEN-PENALTY: penalty for hyphenated breaks (default 50)\n\
@ -766,9 +772,11 @@ FITNESS-PENALTY: penalty for adjacent line tightness mismatch (default 100)\n\
LAST-LINE-RATIO: minimum fill ratio for last line (default 0.5)\n\
CONSEC-HYPHEN-PENALTY: multiplier for consecutive hyphen runs (default 100)\n\
LAST-LINE-SHORT-PENALTY: multiplier for short last lines (default 50.0)\n\
EXTRA-STRETCH: per-line non-justify flexibility in pixels (default 0)\n\n\
EXTRA-STRETCH: per-line non-justify flexibility in pixels (default 0)\n\
EMERGENCY-STRETCH: fixed final-pass emergency stretch in pixels (default 0)\n\n\
(fn LINE-PENALTY HYPHEN-PENALTY FITNESS-PENALTY LAST-LINE-RATIO \
&optional CONSEC-HYPHEN-PENALTY LAST-LINE-SHORT-PENALTY EXTRA-STRETCH)");
&optional CONSEC-HYPHEN-PENALTY LAST-LINE-SHORT-PENALTY EXTRA-STRETCH \
EMERGENCY-STRETCH)");
defun(env, "ekp-c-break-with-arrays", 15, 15, Fekp_c_break_with_arrays,
"Break lines using Elisp's pre-computed prefix arrays (preferred API).\n\n\

View File

@ -153,9 +153,11 @@ typedef struct {
/* Per-line flexibility for non-justify alignment (0 = justify):
* widens max_w, so flexibility = max_w - ideal includes it. */
int32_t extra_stretch;
/* Fixed final-pass emergency stretch, matching TeX's emergency pass. */
int32_t emergency_stretch;
/* Two-pass strategy: strict K-P first; emergency single-box
* breaks only in the second pass (when no valid layout exists). */
/* Two-pass strategy: strict K-P first; emergency transitions only
* in the second pass (when no valid layout exists). */
bool allow_emergency;
} dp_input_t;
@ -209,31 +211,46 @@ static inline bool dp_is_forbidden(const dp_input_t *in, size_t pos)
* Processes position i, trying all end positions k.
* Updates output arrays when better solutions found.
*/
/*
* Emergency break: record a single-box over/underfull line so that the
* DP can never dead-end (every reachable i can always record i+1).
* Demerits are at least as bad as the worst regular line, so these are
* only chosen when nothing better exists. Mirrors
* ekp--dp-relax-emergency in ekp.el.
*/
static inline void dp_relax_emergency(
const dp_input_t *in, size_t i, size_t k,
double prev_dem, int prev_hyph, int prev_lines,
int64_t rest, bool end_hyphen,
typedef struct {
bool present;
double previous_demerits;
size_t start;
int previous_hyphens;
int previous_lines;
int64_t rest;
bool end_hyphen;
} dp_artificial_candidate_t;
static inline void dp_remember_artificial(
dp_artificial_candidate_t *candidate, size_t start,
double previous_demerits, int previous_hyphens, int previous_lines,
int64_t rest, bool end_hyphen)
{
if (candidate->present &&
candidate->previous_demerits <= previous_demerits)
return;
candidate->present = true;
candidate->previous_demerits = previous_demerits;
candidate->start = start;
candidate->previous_hyphens = previous_hyphens;
candidate->previous_lines = previous_lines;
candidate->rest = rest;
candidate->end_hyphen = end_hyphen;
}
static inline void dp_install_artificial(
const dp_artificial_candidate_t *candidate, size_t k,
double *demerits, int32_t *backptrs, int64_t *rest_pixels,
uint8_t *fitness, int32_t *hyphen_counts, int32_t *line_counts)
{
double base = in->line_penalty + EKP_BADNESS_INF;
double dem = prev_dem + base * base + (double)rest * rest;
if (dem < demerits[k]) {
demerits[k] = dem;
backptrs[k] = i;
rest_pixels[k] = rest;
fitness[k] = FITNESS_VERY_LOOSE;
hyphen_counts[k] = end_hyphen ? prev_hyph + 1 : 0;
line_counts[k] = prev_lines + 1;
}
demerits[k] = candidate->previous_demerits;
backptrs[k] = (int32_t)candidate->start;
rest_pixels[k] = candidate->rest;
fitness[k] = FITNESS_TIGHT;
hyphen_counts[k] = candidate->end_hyphen
? candidate->previous_hyphens + 1 : 0;
line_counts[k] = candidate->previous_lines + 1;
}
static void dp_process_position(
@ -250,7 +267,9 @@ static void dp_process_position(
int64_t *rest_pixels,
uint8_t *fitness,
int32_t *hyphen_counts,
int32_t *line_counts)
int32_t *line_counts,
uint8_t *surviving_candidates,
dp_artificial_candidate_t *artificial_candidates)
{
size_t n = in->n;
/* Line 0 (i == 0) may have a different width: first-line indent */
@ -265,20 +284,16 @@ static void dp_process_position(
int64_t lead_space = in->lead_spaces ? in->lead_spaces[i] : 0;
/* Try extending to each position k > i */
bool saw_allowed = false;
for (size_t k = i + 1; k <= n; k++) {
bool is_last = (k == n);
/* Break forbidden here (kinsoku, no-break span): not a
* candidate; keep extending the line. */
if (!is_last && dp_is_forbidden(in, k))
if (!is_last && dp_is_forbidden(in, k)) {
continue;
}
bool is_single_box = (k == i + 1);
/* No permitted break strictly inside [i, k): the run is atomic
* and eligible for emergency handling, like a single box. */
bool atomic_run = !saw_allowed;
saw_allowed = true;
bool end_hyphen = dp_is_hyphen(in, k - 1);
int64_t hyph_w = end_hyphen ? in->hyphen_width : 0;
@ -303,29 +318,28 @@ static void dp_process_position(
int64_t max_w = (int64_t)in->max_prefix[k] - in->max_prefix[i] -
(lead_ideal + lead_stretch) - space_w + hyph_w +
in->extra_stretch;
int64_t emergency_stretch = in->allow_emergency
? in->emergency_stretch : 0;
int64_t effective_max_w = max_w + emergency_stretch;
/* Too long? (last line is never shrunk below its ideal) */
if (min_w > lw || (is_last && ideal > lw)) {
if (atomic_run && in->allow_emergency)
dp_relax_emergency(in, i, k, prev_dem, prev_hyph, prev_lines,
lw - ideal, end_hyphen,
demerits, backptrs, rest_pixels,
fitness, hyphen_counts, line_counts);
if (in->allow_emergency)
dp_remember_artificial(&artificial_candidates[k], i,
prev_dem, prev_hyph, prev_lines,
lw - ideal, end_hyphen);
break; /* No point trying longer lines */
}
if (in->allow_emergency)
surviving_candidates[k] = 1;
/* Valid break? */
bool valid = (min_w <= lw && max_w >= lw) ||
bool valid = (min_w <= lw && effective_max_w >= lw) ||
(is_last && ideal <= lw);
if (!valid) {
/* Rigid underfull atomic run: emergency-record so the
* position after it stays reachable (2nd pass only). */
if (atomic_run && in->allow_emergency)
dp_relax_emergency(in, i, k, prev_dem, prev_hyph, prev_lines,
lw - ideal, end_hyphen,
demerits, backptrs, rest_pixels,
fitness, hyphen_counts, line_counts);
/* An underfull active path survives to later breakpoints. */
continue;
}
@ -333,6 +347,8 @@ static void dp_process_position(
int64_t adjustment = lw - ideal;
int64_t flexibility = (adjustment > 0) ?
(max_w - ideal) : (ideal - min_w);
if (adjustment > 0)
flexibility += emergency_stretch;
double badness;
uint8_t fit;
@ -341,8 +357,12 @@ static void dp_process_position(
/* Single-box line: use fixed flexibility=1, fitness=decent.
* This must come BEFORE is_last check to match Elisp behavior. */
if (is_single_box) {
badness = compute_badness(adjustment, 1);
fit = FITNESS_DECENT;
int64_t flexibility =
(in->allow_emergency && adjustment > 0) ? emergency_stretch : 1;
badness = compute_badness(adjustment, flexibility);
fit = (in->allow_emergency && adjustment > 0)
? compute_fitness(adjustment, flexibility)
: FITNESS_DECENT;
int penalty = end_hyphen ? in->hyphen_penalty : 0;
dem = prev_dem + compute_demerits(badness, penalty,
@ -405,9 +425,8 @@ void ekp_result_destroy(ekp_result_t *r)
*
* All font-dependent calculations happen in Elisp. C module is pure algorithm.
*
* Note: This function now uses dp_process_position() for the core DP logic,
* sharing the same algorithm with process_dp_range(). Any bug fix only needs
* to be made once in dp_process_position().
* dp_process_position() owns the per-breakpoint transition logic used by
* both strict and final passes, including final-pass active-path preservation.
*/
ekp_result_t *ekp_break_with_prefixes(
@ -443,10 +462,15 @@ ekp_result_t *ekp_break_with_prefixes(
uint8_t *fitness = malloc((n + 1) * sizeof(uint8_t));
int32_t *hyph_counts = malloc((n + 1) * sizeof(int32_t));
int32_t *line_counts = malloc((n + 1) * sizeof(int32_t));
uint8_t *surviving_candidates = calloc(n + 1, sizeof(uint8_t));
dp_artificial_candidate_t *artificial_candidates =
calloc(n + 1, sizeof(dp_artificial_candidate_t));
if (!demerits || !backptrs || !rest_pixels || !fitness || !hyph_counts || !line_counts) {
if (!demerits || !backptrs || !rest_pixels || !fitness || !hyph_counts ||
!line_counts || !surviving_candidates || !artificial_candidates) {
free(demerits); free(backptrs); free(rest_pixels);
free(fitness); free(hyph_counts); free(line_counts);
free(surviving_candidates); free(artificial_candidates);
return NULL;
}
@ -469,6 +493,7 @@ ekp_result_t *ekp_break_with_prefixes(
int chp = ekp_global ? ekp_global->consec_hyphen_penalty : 100;
double llsp = ekp_global ? ekp_global->last_line_short_penalty : 50.0;
int32_t xstretch = ekp_global ? ekp_global->extra_stretch : 0;
int32_t estretch = ekp_global ? ekp_global->emergency_stretch : 0;
/* Create unified input structure */
dp_input_t in = {
@ -497,11 +522,12 @@ ekp_result_t *ekp_break_with_prefixes(
.consec_hyphen_penalty = chp,
.last_line_short_penalty = llsp,
.extra_stretch = xstretch,
.emergency_stretch = estretch,
.allow_emergency = false
};
/* Two passes: strict Knuth-Plass first; if the paragraph end is
* unreachable, rerun permitting emergency single-box breaks.
* unreachable, rerun permitting emergency transitions.
* Mirrors ekp--dp-cache-elisp. */
for (int pass = 0; pass < 2; pass++) {
in.allow_emergency = (pass == 1);
@ -514,10 +540,19 @@ ekp_result_t *ekp_break_with_prefixes(
hyph_counts[i] = 0;
line_counts[i] = 0;
}
memset(surviving_candidates, 0, (n + 1) * sizeof(uint8_t));
memset(artificial_candidates, 0,
(n + 1) * sizeof(dp_artificial_candidate_t));
demerits[0] = 0.0;
/* DP: for each valid start, try all ends */
for (size_t i = 0; i < n; i++) {
if (in.allow_emergency && demerits[i] >= EKP_INFINITY &&
!surviving_candidates[i] && artificial_candidates[i].present)
dp_install_artificial(&artificial_candidates[i], i,
demerits, backptrs, rest_pixels,
fitness, hyph_counts, line_counts);
if (demerits[i] >= EKP_INFINITY)
continue;
@ -531,9 +566,17 @@ ekp_result_t *ekp_break_with_prefixes(
rest_pixels,
fitness,
hyph_counts,
line_counts);
line_counts,
surviving_candidates,
artificial_candidates);
}
if (in.allow_emergency && demerits[n] >= EKP_INFINITY &&
!surviving_candidates[n] && artificial_candidates[n].present)
dp_install_artificial(&artificial_candidates[n], n,
demerits, backptrs, rest_pixels,
fitness, hyph_counts, line_counts);
if (demerits[n] < EKP_INFINITY)
break;
}
@ -542,6 +585,7 @@ ekp_result_t *ekp_break_with_prefixes(
if (demerits[n] >= EKP_INFINITY) {
free(demerits); free(backptrs); free(rest_pixels);
free(fitness); free(hyph_counts); free(line_counts);
free(surviving_candidates); free(artificial_candidates);
return NULL;
}
@ -550,6 +594,7 @@ ekp_result_t *ekp_break_with_prefixes(
if (!result) {
free(demerits); free(backptrs); free(rest_pixels);
free(fitness); free(hyph_counts); free(line_counts);
free(surviving_candidates); free(artificial_candidates);
return NULL;
}
@ -568,6 +613,7 @@ ekp_result_t *ekp_break_with_prefixes(
ekp_result_destroy(result);
free(demerits); free(backptrs); free(rest_pixels);
free(fitness); free(hyph_counts); free(line_counts);
free(surviving_candidates); free(artificial_candidates);
return NULL;
}
@ -584,6 +630,7 @@ ekp_result_t *ekp_break_with_prefixes(
free(demerits); free(backptrs); free(rest_pixels);
free(fitness); free(hyph_counts); free(line_counts);
free(surviving_candidates); free(artificial_candidates);
return result;
}
@ -709,6 +756,8 @@ int ekp_init(void)
ekp_global->last_line_ratio = 0.5;
ekp_global->consec_hyphen_penalty = 100;
ekp_global->last_line_short_penalty = 50.0;
ekp_global->extra_stretch = 0;
ekp_global->emergency_stretch = 0;
/* The thread pool is created lazily by the first batch call:
* plain single-paragraph use never starts worker threads. */

View File

@ -82,6 +82,7 @@ typedef struct {
double last_line_short_penalty; /* multiplier for short last lines */
int32_t extra_stretch; /* per-line flexibility for non-justify
* alignment (0 = justify) */
int32_t emergency_stretch; /* fixed final-pass emergency stretch */
} ekp_state_t;
/* Global state instance */

View File

@ -0,0 +1,80 @@
# Forbidden-Run Emergency Boundary
Superseded note: this record explains the earlier rigid-atom repair. Neither
the fullest-prefix fallback nor the later atom-adjacency prohibition is
current. The accepted final-pass model is recorded in
`postmortem/20260802-final-pass-active-path-preservation.md`.
## Context
At 280px, the showcase paragraph before an overwide `ekp-no-break` atom
collapsed into one-glyph CJK lines. The buffer projector reproduced the
semantic plan exactly, so the visible failure was owned by core line breaking,
not redisplay, overlays, resize timing, or projection.
## What the trace showed
The strict pass correctly found no valid candidate where widths jumped from
an underfull CJK prefix to an overfull rigid atom. The emergency pass retained
only the first permitted candidate from each state. That rule guarantees
reachability for arbitrary hard input, but here it made the atom boundary
reachable only through repeated one-glyph emergency transitions.
Replacing that rule globally with the latest underfull candidate was also
wrong. It fixed the screenshot but changed emergency layouts for ordinary
Latin text: discretionary hyphens disappeared and existing DP-parameter
sensitivity changed. Three established regressions failed independently,
while the unmodified baseline passed them.
## Decision
Keep the original first-candidate emergency transition. In the emergency pass
only, remember the latest permitted underfull candidate. Use that additional
candidate only when one or more forbidden break positions are crossed and the
next permitted candidate is already overfull.
This assigns the exception to the event that creates it: a rigid or otherwise
forbidden run skipping the valid width range. The Elisp 1D path, looseness/
parshape path, and C engine implement the same state transition.
## Rejected alternatives
- Compensate in `ekp-buffer.el`: projection would diverge from the semantic
plan and the public string renderer would remain wrong.
- Break the protected atom: this violates the `ekp-no-break` contract.
- Replace every first-candidate emergency with the fullest underfull one: this
changes unrelated emergency layout, hyphenation, and cost sensitivity.
- Add resize debounce or cache invalidation: the failure is deterministic in
a fresh batch plan and is unrelated to time or stale state.
## Consequences
Rigid atoms remain intact, their ordinary prefix uses the fullest permitted
line, and source-clean projection is unchanged. Ordinary emergency behavior,
strict Knuth-Plass results, discretionary hyphenation, and DP parameter
sensitivity retain their previous semantics. The only additional DP state is
three fallback values plus a flag local to one candidate scan.
Automatic inline no-hyphen and explicit hard atoms are different contracts.
The later policy work keeps automatic inline code breakable at source spaces.
For explicit `ekp-no-break`, the ordinary boundary immediately before the
following hard atom is forbidden before DP; that adjacency rule prevents a
manual hard atom from stranding the preceding ordinary box. It is separate
from this postmortem's emergency forbidden-run fallback, which applies only
after a forbidden run skips the valid width range.
## Verification and rollback
The focused regression failed before the fix and passes in Elisp, C, and the
public buffer path. Normal, seeded-random, and per-test isolated 201-test
suites pass, as do 300 C/Elisp fuzz cases, warning-as-error Elisp compilation,
pinned package-lint, checkdoc, release/dictionary gates, and warning-clean
debug, ASan/UBSan, and portable C builds.
Reviewed 62.7-second evidence at
`/private/tmp/ekp-atom-gui-final-Beg8hb` covers nine checkpoints across
480→280→340→280 and returns PASS: the prefix stays on one screen line, the
atom remains intact, source is exact, overlays remain zero, the C engine is
active, and no black or transient vertical frame appears. Rollback is a
direct revert of the forbidden-run fallback and its tests; no API, schema,
cache, or stored-data migration is involved.

View File

@ -0,0 +1,162 @@
# Policy Compilation Boundary
Superseded note: the historical hard-atom adjacency prohibition below was
removed by `task036`. Explicit atoms forbid only interior breaks; final-pass
reachability is owned by core K-P as recorded in
`20260802-final-pass-active-path-preservation.md`.
## Context
Configurable break policy could have been implemented by teaching every
planning caller about inline code, tokens, mode profiles, and local
overrides. That would have spread policy precedence across core DP,
buffer projection, C marshalling, showcase code, and diagnostics.
The accepted task034 contract required inline code to default to legal
wrapping without dictionary hyphenation, explicit `ekp-no-break` to remain
the only manual hard atom, token categories to have independent defaults,
and the existing 15-argument / 15-field C boundary to stay stable unless
architecture review was reopened.
## Decision
Compile policy before dynamic programming. The buffer layer resolves public
configuration and annotates exact structural intervals; the core turns those
intervals into the existing hyphenation, no-hyphen, and forbidden-break
vectors before DP. The semantic plan still preserves the original source
string and source properties for renderers and diagnostics.
This keeps policy as input classification, not a second DP algorithm or a
post-projection correction.
## Rejected alternatives
- Make inline code a hard atom by default. This caused the visible orphan
glyph failure and made ordinary code-like prose too rigid.
- Add a broader C schema for policy objects. The current vectors already
express the required break permissions, and expanding the ABI would add
release risk without a new semantic capability.
- Handle inline faces in the buffer projector after planning. Projection
would disagree with the semantic plan and C parity evidence.
- Copy mode profiles into buffer locals automatically. That makes consulted
defaults look like user overrides and hides precedence bugs.
## Consequences
Policy precedence is explicit: region properties, then explicit local values,
then mode profiles, then global defaults. Block faces stay paragraph-level
verbatim; inline faces affect only their exact spans. Manual `ekp-no-break`
remains session-local and strictly stronger than automatic policies.
The C boundary remains stable, but every new policy that affects break
legality must be reflected in the compiled paragraph inputs before DP. GUI
verification must keep separate examples for automatic inline wrapping,
manual hard atoms, and verbatim blocks so later regressions cannot pass by
collapsing all three concepts into one rendering path.
## Performance root causes
The first performance boundary was paragraph identity. The semantic signature
included policy measure unconditionally, so width-only policy context rebuilt
paragraphs even when no break rule actually depended on measure. The fix
removed measure from paragraph semantic identity and confined measure-sensitive
work to the policy analysis tier.
The second boundary was property-run identity. Clearing volatile properties
could split otherwise equal filtered property runs, making equivalent source
look different to the cache. Canonicalizing adjacent equal filtered intervals
restores the intended stable identity.
The third boundary was policy analysis order. Full policy analysis ran before
paragraph-cache hits, so repeated layout requests paid the analysis cost even
when the paragraph itself was reusable. The implementation now uses a bounded
two-tier full-analysis cache keyed by clean source, canonical layout and raw
face policy intervals, policy signature, and width context. The measure tier
is used only for actual width-sensitive no-break or overflow candidates.
The fourth boundary was semantic plan assembly. Repeated same-paragraph and
same-width requests duplicated plan construction. A bounded per-paragraph plan
cache stores the reusable plan and returns consumer-owned copies of every
plan-owned mutable payload so later renderer or buffer mutations cannot poison
the cached plan. The paragraph pointer remains intentionally shared because
the older paragraph cache owns that lifetime and append planning uses stable
paragraph identity.
G006 cleanup exposed two ownership boundaries during independent review.
First, shallow semantic-plan copies were not enough: nested line/gap/glue
payloads could still alias the cached plan. Second, malformed non-nil C
results were too soft if they reached Elisp fallback. G007 resolved those
first blockers by deep-copying plan-owned mutable payloads at the consumer
boundary and by making `ekp-backend-contract-error` the contract for malformed
non-nil single or batch backend results. Nil whole results and nil per-item
break results remain the only C soft-failure path that may fall back to Elisp.
G007 final review then found one remaining string-leaf alias: `copy-tree`
does not copy strings inside the layout context, so a returned context could
mutate a dynamic policy suffix and poison the cached context. G008 resolves
that final blocker with the same recursive context copier at both context
snapshot/cache-key creation and returned-plan copying. The copier owns conses,
vectors, and strings; `para` remains the only deliberate shared exception.
The explicit hard-atom adjacency rule is also documented here because it is
part of policy compilation, not the emergency fallback. Before DP runs, the
ordinary boundary immediately before a following explicit `ekp-no-break` atom
is forbidden. The later G009 line-breaking correction is narrower and belongs
to core K-P: ordinary underfull final-pass candidates receive finite
emergency stretch and remain normal badness/demerits candidates, while fixed
emergency transitions are reserved for truly overfull hard/atomic runs.
## Verification and remaining gate
Recorded evidence so far: G002 focused core policy and C parity gates pass;
G003 buffer policy ownership and public controls pass; G004 GUI verifier ERT
7/7 and clean single-window evidence at `/tmp/ekp-g004-evidence.Tp77dW`
passes 12/12 checkpoints with automatic inline wrapping, source-space
internal breaks, explicit hard atom, block verbatim, exact source, zero
overlays, C active, and no stale policy projection. G005 records layout
parity true in the formal four-interleaved evaluator; core baseline p50/p95
38.6679/51.6782ms versus candidate 21.7102/33.0040ms for
43.8549%/36.1354% gains; resize baseline p50/p95 43.3831/55.6250ms versus
candidate 22.0919/32.8202ms for 49.0773%/40.9973% gains; both candidate p95
values under 50ms and both gain sets at least 20%.
The live evaluator records the source-instrumented locked goal as the known
`validation_failed` debt, with parity, zero-work, GC, conflict, and
all-width-nonregression checks true. Current C p95/p99 is 26.449/26.740ms,
a 76.65%/77.23% improvement. Current Elisp p95/p99 is 49.940/52.017ms, a
91.64%/91.35% improvement. This is consistent with historical open
`issue018` and is not a regression. The byte-compiled production public path
passed three runs with zero GC: C append p99 1.361-1.368ms, C hard p99
1.876-1.891ms, Elisp append p99 1.692-1.775ms, and Elisp hard p99
2.100-2.230ms.
G005 repository gates are complete: default ERT 262/262; seeded permuted ERT
seed 20260728 255/255; isolated per-test process suite exit 0; targeted
alias guard passed; property fuzz 300/300; warning-as-error byte
compilation; pinned package-lint at
`35996f478d81e51dae4fa30d051f741895d07399` exit 0 with only an external
obsolete warning from the local names dependency; empty checkdoc; release,
49-entry dictionary manifest, pinned dictionary update, shell syntax, CI
YAML, and diff-check passed; portable/native/debug/sanitize C builds were
warning-clean; module 1.6/4-thread smoke passed; and focused C passed 19/19
including the 15-argument/15-field boundary.
G006 cleanup edited only `ekp.el` and `ekp-buffer.el`, removing redundant
policy/cache code without changing the locked behavior. Its first independent
review found the shallow-copy and C-boundary blockers recorded above, so G006
is review-blocked historically rather than final-clean.
G007 resolved the first review blockers, then G008 resolved the final
string-leaf context blocker. Fresh G008 gates: formal resize parity true;
core baseline p50/p95 36.595/48.357ms versus candidate 23.266/36.363ms for
36.42%/24.80% gains; resize baseline p50/p95 41.443/53.541ms versus
candidate 23.761/36.889ms for 42.67%/31.10% gains; ERT 268/268; property
fuzz 300/300; release gate pass; and the byte-compiled current public path
reports zero GC with C append/hard p99 1.440-1.464/2.022-2.050ms and Elisp
append/hard p99 1.648-1.687/2.136-2.335ms, all below 16ms. After the final
warning-clean test-fixture and C README remediation, independent code review
returned APPROVE and the subsequent architecture review returned CLEAR.
This record explains the implementation boundary. `task034` is closed for
developer work and independent review. `issue021` closed after the user's
2026-08-02 visual confirmation.

View File

@ -0,0 +1,92 @@
# Final-Pass Active-Path Preservation
## Context
The narrow showcase could still isolate a CJK glyph after ordinary underfull
final-pass lines were moved onto fixed emergency stretch and normal K-P
badness. The strict pass, renderer, policy compiler, units, and source
projection were not choosing those lines. The failure remained in the core
final-pass transition model shared by the Elisp and C implementations.
Two compensations had accumulated around the symptom: a high fixed-cost
transition described as hard/atomic-specific, and a rule forbidding the
otherwise legal boundary immediately before a manual hard atom. Both encoded
content and adjacency into a reachability problem.
## Root Cause
Fixed emergency stretch solves one problem: it gives ordinary underfull
candidates finite flexibility so adjustment ratio, badness, fitness, and
demerits can compare them globally. It does not solve the separate case where
an overfull candidate would remove the last active final-pass path.
The implementation treated that second case as a special hard-atom line with
a large invented cost. That is not the role of TeX's
`artificial_demerits`. In the final pass TeX preserves reachability when the
active list would otherwise lose the remaining path; the artificial break
adds no demerits. It is a control-flow safeguard, not another line-quality
score and not a token-class rule.
The upstream reference is the pinned TeX Live `tex.web` at commit
`1a25c04b49317750330b4cf95994ea0d08f9d5ec`:
https://raw.githubusercontent.com/TeX-Live/texlive-source/1a25c04b49317750330b4cf95994ea0d08f9d5ec/texk/web2c/tex.web
## Decision
Keep the strict pass unchanged.
In the final pass:
1. Ordinary underfull candidates receive the resolved fixed
`ekp-emergency-stretch-pixel` and use the normal adjustment-ratio,
badness, fitness, and demerits pipeline.
2. Remember the best provisional overfull candidate for each breakpoint.
3. If no normal state exists at that breakpoint and no non-overfull active
candidate survives to it, install the provisional path with tight fitness
and zero incremental demerits.
The artificial path therefore cannot beat a surviving normal path. It exists
only at the path-extinction boundary. The Elisp 1D, Elisp
looseness/parshape, and C paths implement the same state transition.
Delete the atom-adjacency prohibition. An explicit `ekp-no-break` interval
forbids only its interior breaks; otherwise legal boundaries immediately
before and after it remain legal. An overwide atom stays intact but may share
an overflow line with preceding ordinary content.
## Test-Oracle Failure
The isolated-CJK oracle originally checked only the lines returned by the
planner. An empty plan contained no isolated line, so the assertion could
pass while the core had failed to cover any source. The oracle now first
requires a nonempty, contiguous plan whose ranges cover the entire source,
then checks for isolated CJK lines. A direct regression also proves that an
artificial line adds zero demerits to the preceding path.
## Rejected Alternatives
- A CJK orphan penalty would encode a visible sample instead of the K-P
invariant.
- Unit suffix or hard-atom scoring would make content classification affect a
content-independent final-pass safeguard.
- Forbidding the boundary before an atom would delete a legitimate K-P choice
and compensate in policy compilation for a core DP bug.
- Renderer reshaping would make display diverge from the semantic plan.
- A large artificial cost would turn reachability into a competing heuristic
and differ from TeX's zero-increment behavior.
## Consequences
The public configuration remains the fixed emergency-stretch dimension; no
new orphan, unit, or atom-adjacency option exists. The public C contract stays
at 15 paragraph fields and 15 entry arguments. Renderer glue distribution
uses the selected line's actual rest and actual glue-set proportions.
Focused regressions pass 8/8, emergency selection passes 10/10, the
core/buffer/GUI oracle passes 8/8, full ERT passes 288/288, seeded and isolated
suites pass 279/279, fuzz passes 300/300, and all Elisp/C build, static,
release, and performance gates pass. Reviewed 42.78-second dynamic GUI
evidence at `/tmp/ekp-g009-evidence-retry.UOpPNp` covers
480→168→280→168 plus a no-hyphen→normal policy transition and returns
`VERDICT=PASS` with one window, exact source, zero overlays/stale spans,
active C, and no isolated CJK source line.

View File

@ -0,0 +1,65 @@
# Final-Pass Emergency Stretch
Superseded note: the fixed-dimension emergency-stretch decision remains
current, but the historical hard/atomic fixed-cost exception below does not.
The accepted TeX active-path semantics are recorded in
`20260802-final-pass-active-path-preservation.md`.
## Context
The narrow showcase still produced isolated CJK source lines after the
policy work split automatic inline code from explicit hard atoms. That made
the earlier forbidden-run explanation too narrow: the visible failure was not
owned by unit suffix policy, buffer projection, or a screenshot-specific CJK
penalty. It was the final K-P pass assigning ordinary underfull candidates to
a fixed-cost emergency path outside the normal badness and demerits model.
## Decision
Keep the strict pass unchanged. When strict K-P cannot reach the paragraph
end, rerun with a finite background emergency stretch available to ordinary
underfull candidates. Those candidates still compute adjustment ratio,
badness, fitness, and demerits, so the global DP chooses between them by the
same model as ordinary K-P.
Task035 tried making that stretch scale with the line/candidate width. Full
regression falsified that detail. Task036 must use a TeX-style fixed
dimension instead: `ekp-emergency-stretch-pixel` is nil for an automatic
value around three display-font `M` widths, or a non-negative integer for a
fixed pixel value.
Keep the artificial fixed-cost emergency transition only for a truly overfull
first permitted hard or atomic run. That case has no ordinary candidate to
score. The Elisp 1D path, Elisp looseness/parshape path, and C engine use the
same rule.
## Why the old fallback was wrong
The earlier repair treated the symptom as a forbidden-run boundary problem
and remembered a fuller prefix. That explained one rigid-atom screenshot, but
it left the deeper model split in place: some ordinary underfull lines were
still not compared by normal badness, fitness, and demerits. At narrow widths
that flattened costs enough for one-glyph lines to remain competitive.
The accepted direction matches TeX's emergency-pass shape more closely:
emergency stretch is background flexibility for badness calculation, not a
separate shortcut that bypasses the line-quality model. It must be a fixed
dimension for the paragraph context, not a value derived from each line
measure.
## Test lesson
The first 84px/168px CJK oracle was invalid because it assumed two CJK glyphs
always fit when the width is twice one glyph. It ignored glue, kinsoku, and
attached punctuation. The replacement tests use the real mixed showcase text
and reject isolated CJK source lines through the core, public buffer path, and
GUI oracle.
## Consequences
There is no CJK-orphan penalty, no number-unit special case, and no renderer
compensation. Policy compilation still decides which gaps are legal before
DP. Core K-P owns the final line choices, the renderer must agree with the
chosen fixed stretch dimension, and both engines must stay in parity whenever
emergency stretch behavior changes. The 15-field C paragraph ABI remains a
constraint for task036.

View File

@ -26,7 +26,7 @@ typesetting, entirely inside Emacs.
- **Text properties preserved** — faces, colors and other properties
survive justification; inserted hyphens inherit the face of the word
they break.
- **Robust on hard input** — unbreakable overlong tokens (URLs, long
- **Robust on hard input** — unprotected overlong tokens (URLs, long
words at narrow widths) degrade to emergency breaks instead of losing
text; every input produces output.
- **Optional C module** — a dynamic module runs the DP in C with a
@ -173,15 +173,17 @@ blocking input in an unbounded Knuth-Plass pass; `M-x ekp-diagnose` reports
the reason. Run `M-x ekp-refill-paragraph` when you explicitly want the
unbounded full-quality pass for that paragraph.
Mode presets for verbatim protection — one call each:
Mode presets for explicit local protection — one call each:
```elisp
(add-hook 'org-mode-hook #'ekp-org-setup)
(add-hook 'markdown-mode-hook #'ekp-markdown-setup)
```
`ekp-auto-justify-mode` also applies the matching preset automatically
in Org and Markdown buffers when you have not configured your own.
`ekp-auto-justify-mode` consults `ekp-buffer-mode-policy-alist`
automatically in Org and Markdown buffers. It does not copy profile
values into buffer-local variables unless you explicitly call the setup
functions above.
### Protecting code and other verbatim text
@ -190,10 +192,22 @@ in Org and Markdown buffers when you have not configured your own.
`ekp-buffer-skip-faces` (e.g. `org-block`, `markdown-code-face`), or
matched by the buffer-local function `ekp-buffer-skip-predicate`
pass through completely untouched.
- Inline level: spans carrying `ekp-no-break`
- Automatic inline level: faces listed in `ekp-buffer-inline-faces`
(Org `org-code`/`org-verbatim`, Markdown inline code by default through
mode profiles) use `ekp-inline-code-policy`. The default `no-hyphen`
keeps source spaces literal and suppresses dictionary hyphenation, but it
may still wrap at legal source boundaries. Automatic `no-break` spans
downgrade to `no-hyphen` when wider than the effective measure.
- Explicit hard atom level: spans carrying `ekp-no-break`
(`M-x ekp-no-break-region`) become rigid atoms — never broken,
never hyphenated, spacing kept literal — ideal for inline code,
product names, or numbers with units.
product names, or numbers with units. An atom wider than the measure stays
intact but is not guaranteed a line of its own: the final pass may place it
on one overflow line with preceding ordinary content. Ordinary underfull
candidates use fixed emergency stretch and normal K-P costs. If an
overfull candidate would otherwise extinguish every active final-pass path,
the core preserves the last path with TeX-style artificial demerits. There
is no CJK-orphan, unit, or screenshot-specific rule.
Manual properties are deliberately **current-buffer-session only**:
plain-text saving and reopening do not persist them. Use
@ -235,6 +249,40 @@ not renderable (text cannot start before the line origin).
## Configuration
### Break policy and measure
The break-policy defaults are intended to make code readable without making
every code-looking span a hard atom:
| Option | Default | Scope | Effect |
|:-------|:--------|:------|:-------|
| `ekp-inline-code-policy` | `no-hyphen` | global, profile, local | `normal`, `no-hyphen`, or automatic fitting `no-break` for inline-face spans |
| `ekp-hyphenation` | `auto` | global, profile, local, region via command | `auto`/`on` use dictionaries when available; `off` suppresses discretionary hyphens |
| `ekp-token-break-policies` | URL/path/identifier `no-hyphen`, number-unit `no-break` | global, profile, local | per-token automatic policies; local/profile values merge by token category |
| `ekp-number-unit-suffixes` | common CSS, time, data, frequency, and metric units | global, profile, local | suffixes recognized by the compact number-unit classifier |
| `ekp-kinsoku-profile` | `common` | global, profile, local | `common`, `zh`, `ja`, `off`, or `custom` CJK line-start/end prohibitions |
| `ekp-cjk-no-line-start-extra` / `ekp-cjk-no-line-end-extra` | `""` | global, profile, local | additions used by the `custom` profile |
| `ekp-overlong-token-policy` | `emergency` | global, profile, local | `emergency`, `overflow`, or `natural` for ordinary overlong Latin-like tokens |
| `ekp-buffer-measure` | `narrowest-window` | global, profile, local | `narrowest-window`, fixed pixel integer, or `(max . PIXELS)` |
| `ekp-buffer-skip-faces` | profile-dependent | global, profile, local | paragraph-level verbatim faces |
| `ekp-buffer-inline-faces` | profile-dependent | global, profile, local | exact inline spans using `ekp-inline-code-policy` |
| `ekp-buffer-mode-policy-alist` | Org and Markdown profiles | global/local safe value | mode profiles consulted by automatic and manual buffer layout |
Effective precedence is deterministic: explicit region text properties
first, then explicit buffer/file/dir-local values, then the first matching
major-mode profile, then global defaults. `ekp-break-policy` region values
are `normal`, `hyphenate`, and `no-hyphen`; they override automatic token
or inline policies for the exact region but never create a hard atom.
`ekp-no-break` remains the only explicit hard-atom property and wins over
every automatic policy.
All listed variables have closed safe-local predicates where file/dir local
configuration is supported. `ekp-diagnose` reports the requested measure,
the narrowest live window, the effective measure, overflow risk, conflict
count, and the active inline/hyphenation/kinsoku/overlong policy summary.
The EKP menu exposes diagnose plus region commands for normal break,
hyphenation on, hyphenation off, clear break policy, no-break, and verbatim.
### Hyphenation language
```elisp

View File

@ -22,7 +22,7 @@ Emacs-kp 在 Emacs 内部完整实现了 Knuth-Plass 最优断行算法,支持
搜索、语法、保存及普通 Elisp 文本 API 看到的都是源字符。
- **文本属性保留** — face、颜色等属性完整保留;断词插入的连字符继承所
在单词的样式。
- **困难输入不丢内容** — 超长不可断 token(URL、窄栏长词)退化为紧急
- **困难输入不丢内容**未受保护的超长 token(URL、窄栏长词)退化为紧急
断行而不是吞掉文本;任何输入都有输出。
- **可选 C 模块** — 动态模块用 C 执行 DP,线程池并行处理多个段落(见
性能数据)。
@ -145,7 +145,7 @@ Elisp。已启用模块若 signal,则作为后端契约错误直接呈现。若
Knuth-Plass 计算阻塞输入;`M-x ekp-diagnose` 会报告这一原因。确实需要
对该段执行无上限完整质量排版时,显式运行 `M-x ekp-refill-paragraph`
各 mode 的 verbatim 保护预设——各一行:
各 mode 的显式本地保护预设——各一行:
```elisp
(add-hook 'org-mode-hook #'ekp-org-setup)
@ -153,7 +153,9 @@ Knuth-Plass 计算阻塞输入;`M-x ekp-diagnose` 会报告这一原因。确实
```
在 Org 与 Markdown buffer 里,若你没有自定义配置,
`ekp-auto-justify-mode` 会自动套用对应预设。
`ekp-auto-justify-mode` 会自动查询 `ekp-buffer-mode-policy-alist`
它不会把 profile 值复制成 buffer-local 变量;只有显式调用上面的
setup 函数时才会写入本地 face 列表。
### 保护代码块与 verbatim 文本
@ -161,9 +163,18 @@ Knuth-Plass 计算阻塞输入;`M-x ekp-diagnose` 会报告这一原因。确实
face 在 `ekp-buffer-skip-faces` 列表中(如 `org-block`
`markdown-code-face`)、或被 buffer-local 的
`ekp-buffer-skip-predicate` 判定的段落**原样跳过**,一个字节都不动。
- 行内级:带 `ekp-no-break` 属性的区间(`M-x ekp-no-break-region`)
- 自动行内级:face 在 `ekp-buffer-inline-faces` 中的精确区间
(Org 的 `org-code`/`org-verbatim`,Markdown 行内代码默认由 mode
profile 提供)使用 `ekp-inline-code-policy`。默认 `no-hyphen`
保留源空格字面宽度并禁止词典断词,但仍可在合法源边界换行。自动
`no-break` 区间若宽于有效栏宽,会降级为 `no-hyphen`
- 显式硬原子级:带 `ekp-no-break` 属性的区间(`M-x ekp-no-break-region`)
成为刚性原子——不断行、不断词、空格保持字面宽度——适合行内代码、
产品名、数字加单位。
产品名、数字加单位。原子宽于栏宽时仍保持完整,但不保证独占一行:
最终遍可能把它与前面的普通内容放在同一条溢出行。普通欠宽候选使用
固定的 emergency stretch 并按正常 K-P 代价评分;若超宽候选会让最终遍
的活动路径全部消失,核心按 TeX 的 artificial demerits 语义保留最后
路径。这里没有中文孤字、单位或截图专用规则。
手动属性明确只在**当前 buffer 会话**有效:普通文本保存与重新打开不会
恢复它们。使用 `M-x ekp-allow-break-region` /
@ -197,6 +208,37 @@ mode face 或 buffer-local 的 `ekp-buffer-skip-predicate`(Org/Markdown
## 配置
### 断行策略与测量宽度
默认策略的目标是让代码可读,但不把所有"看起来像代码"的片段都变成
硬原子:
| 选项 | 默认值 | 作用域 | 含义 |
|:-----|:-------|:-------|:-----|
| `ekp-inline-code-policy` | `no-hyphen` | 全局、profile、本地 | 行内 face 的 `normal`、`no-hyphen` 或自动适配 `no-break` |
| `ekp-hyphenation` | `auto` | 全局、profile、本地、区域命令 | `auto`/`on` 在词典可用时断词;`off` 禁止词典断词 |
| `ekp-token-break-policies` | URL/path/identifier `no-hyphen`,number-unit `no-break` | 全局、profile、本地 | 按 token 类别合并的自动策略 |
| `ekp-number-unit-suffixes` | 常见 CSS、时间、数据、频率、度量单位 | 全局、profile、本地 | 紧凑数字单位识别的后缀 |
| `ekp-kinsoku-profile` | `common` | 全局、profile、本地 | `common`、`zh`、`ja`、`off` 或 `custom` 禁则 |
| `ekp-cjk-no-line-start-extra` / `ekp-cjk-no-line-end-extra` | `""` | 全局、profile、本地 | `custom` profile 使用的附加禁则字符 |
| `ekp-overlong-token-policy` | `emergency` | 全局、profile、本地 | 普通超宽 Latin-like token 的 `emergency`、`overflow` 或 `natural` |
| `ekp-buffer-measure` | `narrowest-window` | 全局、profile、本地 | `narrowest-window`、固定像素整数或 `(max . PIXELS)` |
| `ekp-buffer-skip-faces` | 由 profile 提供 | 全局、profile、本地 | 段落级 verbatim face |
| `ekp-buffer-inline-faces` | 由 profile 提供 | 全局、profile、本地 | 使用行内策略的精确 face 区间 |
| `ekp-buffer-mode-policy-alist` | Org 与 Markdown profile | 全局/安全本地值 | 自动与手动 buffer 排版查询的 mode profile |
有效优先级固定为:显式区域文本属性 > 显式 buffer/file/dir-local 值 >
第一个匹配的 major-mode profile > 全局默认值。区域 `ekp-break-policy`
可取 `normal`、`hyphenate`、`no-hyphen`;它只覆盖精确区间内的自动
token/行内策略,不会创建硬原子。`ekp-no-break` 仍是唯一显式硬原子
属性,并且胜过所有自动策略。
支持 file/dir local 的上述变量都有封闭的 safe-local 谓词。
`M-x ekp-diagnose` 会报告请求宽度、最窄活动窗口、有效宽度、溢出风险、
冲突数量,以及当前行内/断词/禁则/超宽策略摘要。EKP 菜单提供诊断与
区域命令:普通断行、开启区域断词、关闭区域断词、清除区域断行策略、
no-break 与 verbatim。
### 断词语言
```elisp

File diff suppressed because it is too large Load Diff

View File

@ -86,6 +86,24 @@
(should-error (ekp-c-set-penalties "10" 50 100 0.5)
:type 'ekp-c-invalid-input))
(ert-deftest ekp-c-test-penalties-accept-emergency-stretch-eighth-param ()
"The direct C setter accepts a non-negative emergency stretch parameter."
(skip-unless (ekp-c-tests--available))
(should (ekp-c-set-penalties 10 50 100 0.5 100 50 0 7)))
(ert-deftest ekp-c-test-penalties-reject-invalid-emergency-stretch ()
"Invalid emergency stretch inputs use the module input condition."
(skip-unless (ekp-c-tests--available))
(should-error (ekp-c-set-penalties 10 50 100 0.5 100 50 0 -1)
:type 'ekp-c-invalid-input)
(should-error (ekp-c-set-penalties 10 50 100 0.5 100 50 0 "wide")
:type 'ekp-c-invalid-input))
(ert-deftest ekp-c-test-penalties-keep-legacy-four-arg-call ()
"The direct C setter remains compatible with the legacy 4-arg call."
(skip-unless (ekp-c-tests--available))
(should (ekp-c-set-penalties 10 50 100 0.5)))
(ert-deftest ekp-c-test-module-build-uses-argv-in-directory ()
"Interactive builds must not interpolate a module path into a shell."
(let (process-arguments process-directory)

View File

@ -17,15 +17,31 @@
"bcdfghjklmnpqrstvwxz" "word!" "(paren)" "don't"
"test," "end." "«quoted»" "naïve" "" ""))
(defconst fuzz--puncts '("" "" "" "" "" "" "" "" ""))
(defconst fuzz--policy-tokens
'("https://example.test/a_b" "src/core/file_name.el"
"processKeyword42" "3.14MB" "100px"))
(defun fuzz--maybe-policy-propertize (token)
"Return TOKEN with deterministic public policy annotations sometimes."
(pcase (fuzz--rand 8)
(0 (propertize token 'ekp-break-policy 'normal))
(1 (propertize token 'ekp-break-policy 'hyphenate))
(2 (propertize token 'ekp-break-policy 'no-hyphen))
(3 (propertize token 'ekp-no-break t))
(4 (propertize token 'face 'ekp-fuzz-inline-code))
(_ token)))
(defun fuzz--gen-string ()
"Random mixed paragraph of 5-60 tokens."
(let ((n (+ 5 (fuzz--rand 56))) (parts nil))
(dotimes (_ n)
(pcase (fuzz--rand 10)
(pcase (fuzz--rand 11)
;; latin word
((or 0 1 2 3) (push (nth (fuzz--rand (length fuzz--words)) fuzz--words) parts)
(push " " parts))
((or 0 1 2 3)
(push (fuzz--maybe-policy-propertize
(nth (fuzz--rand (length fuzz--words)) fuzz--words))
parts)
(push " " parts))
;; CJK run
((or 4 5 6 7) (let ((len (1+ (fuzz--rand 6)))
(start (fuzz--rand (- (length fuzz--cjk) 7))))
@ -33,7 +49,13 @@
;; CJK punct
(8 (push (nth (fuzz--rand (length fuzz--puncts)) fuzz--puncts) parts))
;; spaces / zwsp
(9 (push (if (= 0 (fuzz--rand 3)) "" " ") parts))))
(9 (push (if (= 0 (fuzz--rand 3)) "" " ") parts))
;; built-in policy token categories
(10 (push (fuzz--maybe-policy-propertize
(nth (fuzz--rand (length fuzz--policy-tokens))
fuzz--policy-tokens))
parts)
(push " " parts))))
(string-trim (apply #'concat (nreverse parts)))))
(defun fuzz--content (s)

File diff suppressed because it is too large Load Diff

View File

@ -31,15 +31,29 @@
(defvar-local ekp-showcase--last-ms nil)
(defvar-local ekp-showcase--parshape-on nil)
(defface ekp-showcase-inline-code
'((t :inherit fixed-pitch
:background "#3a3f4b"
:foreground "#98c379"))
"Face for automatic inline-code policy spans in the showcase."
:group 'ekp)
(defconst ekp-showcase--alignments
'(justify ragged-right center ragged-left))
(defun ekp-showcase--sample ()
"Build the demo text: prose, punctuation, atoms, NBSP, a code block."
(let ((zh1 "汉字排版的质感藏在细节里:开启标点悬挂之后,行尾的句号。逗号,和问号?都会把墨迹之外的空白悬出齐边,右边缘因此在视觉上更加平直。避头尾规则保证「引号」与《书名号》永远紧贴内容,连续闭合标点如此。」也绝不拆行。")
(mixed "中英混排是 Emacs 里最常见的场景:The quick brown fox jumps over the lazy dog, 而 internationalization 这样的长词在窄栏会按 Liang 模式断词,连字符同样参与悬挂。")
(atoms (concat "行内原子演示:代码片段 "
(propertize "(ekp-pixel-justify STR W)"
(mixed (concat "中英混排是 Emacs 里最常见的场景: The quick brown fox "
"jumps over the lazy dog, 而 internationalization "
"这样的长词在窄栏会按 Liang 模式断词。自动行内代码 "
(propertize
"(ekp-pixel-justify paragraph-text target-width 'justify nil)"
'face 'ekp-showcase-inline-code)
" 可以在合法空白边界附近换行,但不会插入"
" discretionary hyphen。"))
(atoms (concat "行内原子演示:手动硬原子 "
(propertize "M-x ekp-demo"
'ekp-no-break t
'face '(:inherit fixed-pitch
:background "#3a3f4b"
@ -230,6 +244,7 @@
"Interactive showcase for ekp typesetting features."
(setq-local truncate-lines t)
(setq-local cursor-type 'bar)
(setq-local ekp-buffer-inline-faces '(ekp-showcase-inline-code))
;; keys stay pinned in the header line; live state lives in the
;; mode line (both always visible)
(setq header-line-format (ekp-showcase--keys-line)))

File diff suppressed because it is too large Load Diff