polytypo 1.4.0 → 1.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. checksums.yaml +4 -4
  2. data/lib/polytypo/data/README.md +15 -0
  3. data/lib/polytypo/data/VERSION +1 -1
  4. data/lib/polytypo/data/fixtures/cs.json +1 -1
  5. data/lib/polytypo/data/fixtures/de-CH.json +1 -1
  6. data/lib/polytypo/data/fixtures/de-DE.json +1 -1
  7. data/lib/polytypo/data/fixtures/el.json +1 -1
  8. data/lib/polytypo/data/fixtures/en-GB.json +50 -1
  9. data/lib/polytypo/data/fixtures/en-US.json +41 -1
  10. data/lib/polytypo/data/fixtures/es.json +1 -1
  11. data/lib/polytypo/data/fixtures/fi.json +1 -1
  12. data/lib/polytypo/data/fixtures/fr-CA.json +1 -1
  13. data/lib/polytypo/data/fixtures/fr.json +9 -1
  14. data/lib/polytypo/data/fixtures/it.json +1 -1
  15. data/lib/polytypo/data/fixtures/locale-resolution.json +1 -1
  16. data/lib/polytypo/data/fixtures/nl.json +1 -1
  17. data/lib/polytypo/data/fixtures/pl.json +1 -1
  18. data/lib/polytypo/data/fixtures/pt-BR.json +1 -1
  19. data/lib/polytypo/data/fixtures/pt-PT.json +1 -1
  20. data/lib/polytypo/data/fixtures/ru.json +1 -1
  21. data/lib/polytypo/data/fixtures/sv.json +1 -1
  22. data/lib/polytypo/data/fixtures/uk.json +1 -1
  23. data/lib/polytypo/data/locales/cs.json +37 -56
  24. data/lib/polytypo/data/locales/de-CH.json +34 -43
  25. data/lib/polytypo/data/locales/de-DE.json +32 -47
  26. data/lib/polytypo/data/locales/el.json +1 -51
  27. data/lib/polytypo/data/locales/en-GB.json +4 -56
  28. data/lib/polytypo/data/locales/en-US.json +4 -68
  29. data/lib/polytypo/data/locales/es.json +29 -66
  30. data/lib/polytypo/data/locales/fi.json +7 -77
  31. data/lib/polytypo/data/locales/fr-CA.json +36 -63
  32. data/lib/polytypo/data/locales/fr.json +41 -70
  33. data/lib/polytypo/data/locales/it.json +22 -65
  34. data/lib/polytypo/data/locales/nl.json +29 -61
  35. data/lib/polytypo/data/locales/pl.json +28 -61
  36. data/lib/polytypo/data/locales/pt-BR.json +28 -53
  37. data/lib/polytypo/data/locales/pt-PT.json +28 -55
  38. data/lib/polytypo/data/locales/registry.json +1 -1
  39. data/lib/polytypo/data/locales/ru.json +30 -45
  40. data/lib/polytypo/data/locales/sv.json +4 -63
  41. data/lib/polytypo/data/locales/uk.json +27 -55
  42. data/lib/polytypo/data/rules/apostrophe.md +183 -20
  43. data/lib/polytypo/data/rules/modes.md +29 -4
  44. data/lib/polytypo/data/rules/nbsp.md +20 -6
  45. data/lib/polytypo/data/rules/order.json +2 -2
  46. data/lib/polytypo/data/rules/pipeline-idempotency.md +3 -2
  47. data/lib/polytypo/data/rules/quotes.md +234 -9
  48. data/lib/polytypo/data/rules/symbols.md +25 -5
  49. data/lib/polytypo/engine/rules/apostrophe.rb +14 -1
  50. data/lib/polytypo/version.rb +1 -1
  51. metadata +1 -1
@@ -1,8 +1,9 @@
1
1
  # Rule: `quotes`
2
2
 
3
3
  **Order:** 40. **Default:** on. **Modes:** text, html, markdown, yaml.
4
- **Spec version:** 1.1.0 (0.4.1 for everything except the universal medial-`n` elision veto
5
- described in §3.2 and the History section below).
4
+ **Spec version:** 1.4.0 (0.4.1 for everything except the universal medial-`n` elision veto
5
+ (1.1.0) and the span-boundary elision veto (1.4.0), both described in §3.2 and in the History
6
+ section below).
6
7
 
7
8
  ---
8
9
 
@@ -86,6 +87,62 @@ normalising the spacing immediately inside each pair.
86
87
  imports (`scripts/lib/is-letter.mjs`), so validation and the runtime engine cannot classify a
87
88
  code point differently.
88
89
 
90
+ - `quotes.elisionClitics` (spec 1.4.0) — an object with two arrays, `before` and `after`, each
91
+ a list of literal lowercase strings, consulted only by the **span-boundary elision veto**
92
+ (§3.2) to **decline** pairing a `NARROW` mark as a quotation when the mark is flush against an
93
+ inline span boundary on one side and a listed word fragment on the other. Both lists may be
94
+ empty, and empty is the default for a locale with no citable closed set of such fragments.
95
+
96
+ **Both lists are defined positionally, not by what the fragment attaches to.** `before` is
97
+ matched against the maximal `LETTER` run ending immediately *before* the mark; `after` against
98
+ the run beginning immediately *after* it. Attachment direction is the linguistic motivation for
99
+ an entry, never the test — and the two do not coincide here, because the whole point of this
100
+ veto is that the word the fragment attaches to may be on the far side of a span boundary and
101
+ therefore absent from the rule's input. In `` `x`'s `` the possessive attaches back to the `x`
102
+ inside the code span, yet what stands to the mark's left is the `MARKER`; the fragment this
103
+ field must name is the `s` on the **right**. Reading the lists as attachment claims makes
104
+ `after` look inert (a fragment with a `LETTER` on each side is already vetoed as medial) and is
105
+ the one misreading to guard against.
106
+
107
+ - **`before`** — French `l'`, `d'`, `qu'`, `jusqu'` give `l`, `d`, `qu`, `jusqu`; Portuguese
108
+ `d'`, `n'`, `pel'`, `Sant'` give `d`, `n`, `pel`, `sant`; Italian `l'`, `un'`, `d'`.
109
+ - **`after`** — English `x's` gives `s`; Dutch `'s morgens`, `'t was`, `'ns kijken` give `s`,
110
+ `t`, `ns`.
111
+
112
+ **An entry is the whole maximal run, never a prefix of one.** French needs `jusqu`, `lorsqu`,
113
+ `puisqu` and `quoiqu` as entries of their own: the run in `jusqu'à` is `jusqu`, so an entry
114
+ `qu` cannot match it. The same arithmetic is why Italian's `l` does not cover `dell'arte` (the
115
+ run is `dell`), which makes a partial list honest rather than misleadingly narrow.
116
+
117
+ "Elision" here covers the possessive, exactly as `apostrophe.md` §3.3 case 3 does in its own
118
+ name ("Trailing elision or possessive") — English `'s` is not an elision and is the one entry
119
+ that locale has.
120
+
121
+ **Every code point of every entry must be a `LETTER`**, and an entry must be non-empty. The
122
+ constraint is enforced at the data boundary by `locale.schema.json` and by
123
+ `scripts/validate-spec.mjs`, for the reason §3.2 gives: the matcher walks a maximal `LETTER`
124
+ run, so an entry containing anything else could never match and would be dead data rather than
125
+ a rejected claim. Entries are authored **lowercase**; §3.2's matcher folds only the first code
126
+ point, and only ASCII `A`–`Z`, so `L'` matches an entry `l` and `QU'` does not match `qu`.
127
+ What is *enforced* at the data boundary is exactly that much and no more: an entry whose first
128
+ code point is ASCII `A`–`Z` is rejected, because the fold makes it unmatchable by
129
+ construction. An entry capitalised in a script the fold does not reach is not rejected — it is
130
+ simply matched as written, which is well-defined and identical in every runtime, so the
131
+ authoring convention is stated as a convention rather than as a validated constraint.
132
+
133
+ **The leniency is therefore Latin-only in effect, and that is a scoping fact, not an
134
+ oversight.** A Cyrillic or Greek entry cannot serve: `ru`'s own cited example is `Д'Аламбер`
135
+ with a capital U+0414, which a lowercase entry `д` will not match, while an uppercase entry
136
+ would miss `д'Артаньян` in the same citation. Widening the fold is `ARCHITECTURE.md` §4.4's
137
+ banned territory (no locale-dependent case mapping — Turkish dotless ı), so a locale whose
138
+ script the fold does not reach is `[]` on mechanism grounds, before its orthography is even
139
+ consulted. `ru`, `uk` and `el` are `[]` for exactly this reason as well as their own.
140
+
141
+ A decline-only pair of lists can never widen what this rule pairs, only narrow it — the same
142
+ principle `elisionIdioms` above rests on, stated normatively in `nbsp.md` §2.1 and governing
143
+ every list-valued field in `locale.schema.json`. Both lists empty makes the veto a total
144
+ no-op and classifies every mark exactly as spec 1.3.1 did.
145
+
89
146
  `innerSpace` is read and acted on in **one direction only**: `quotes` *deletes* an inner space
90
147
  when the assigned pair's `innerSpace` is `"none"`, and never inserts or converts one. Insertion
91
148
  and conversion stay with `nbsp` (order 70), exactly as 0.1.0 always said. See §3.7.
@@ -446,6 +503,107 @@ over these bytes could. The trade is deliberate and was made with the numbers in
446
503
  for this same shape by declining **every** short quotation in **every** locale, a far larger and
447
504
  far commoner class of error than quoting the single letter *n*.
448
505
 
506
+ **Span-boundary elision veto (spec 1.4.0, `NARROW` marks only, locale data
507
+ `quotes.elisionClitics`, literal reads).** The two vetoes above both need a real code point on
508
+ each side of the mark. `modes.md` §3.2's boundary marker is not one, so in `html` and `markdown`
509
+ a mark written flush against an inline span — `` `x`'s ``, `<code>x</code>'s`, `*x*'s`,
510
+ `l'<em>idée</em>` — has a `MARKER` where the attaching word would be, escapes both, and is
511
+ classified as an ordinary quotation candidate. `Llit = MARKER` is in `OPENISH`, so the mark
512
+ `canOpen`; inside a quotation it has a partner to take, and the accepted pairing inverts:
513
+ `He says 'avoid `` `x` ``'s printer.' Done.` was typeset with the possessive opening the
514
+ quotation and the author's opening mark demoted to a closing glyph, in every locale (issue #53,
515
+ measured on 1.3.1).
516
+
517
+ This veto reads the attaching word instead of the missing code point:
518
+
519
+ > For a mark at `i` with `cp[i] ∈ NARROW`, **both** tests below are evaluated and either firing
520
+ > sets both capabilities false.
521
+ >
522
+ > - **`Llit = MARKER`** (the mark is flush against a span boundary on its left). Let `R` be the
523
+ > maximal run of `LETTER` code points starting at `i+1`, and `outer` the code point after that
524
+ > run, or `NONE`. If `R` is non-empty, `outer ∉ ALNUM`, and `R` equals an entry of
525
+ > `quotes.elisionClitics.after` — **every code point exact, except the first, which folds
526
+ > ASCII `A`–`Z` to `a`–`z` by code point and nothing else** — then veto.
527
+ > - **`Rlit = MARKER`.** Symmetric: `L` is the maximal `LETTER` run *ending* at `i-1`, `outer`
528
+ > the code point before it, and the comparison is against `quotes.elisionClitics.before`.
529
+ >
530
+ > The folding is the one `elisionIdioms` already specifies, for the same reason and by the same
531
+ > mechanism — never a platform locale function (`ARCHITECTURE.md` §4.4).
532
+
533
+ **One literal neighbour must be the marker, and that is the whole scope of this veto.** With a
534
+ real code point on both sides the medial-elision veto above already declines the same shape
535
+ (`x's` needs no span boundary to be a possessive), so this one adds nothing there and is not
536
+ consulted. In `text` mode that makes the veto vacuous: no marker is produced at all, so a
537
+ locale's lists cannot change what `text` does.
538
+
539
+ **In `yaml` the veto is reachable only if the scan ever puts two spans on one line, and it must
540
+ not be special-cased either way.** `modes.md` §3.5 step 2 says the −2 line marker is the
541
+ *common* case there, not the only one, so this document does not claim the veto is unreachable
542
+ in `yaml`. What it does claim is narrower and checkable: the scan yields no spans at all inside
543
+ a flow collection (`modes.md` §3.8.4 step 9), and a block mapping carries one key per line, so no
544
+ construction has yet been exhibited in which two `yaml` spans are separated by a gap without a
545
+ line terminator — and a −2 marker is in `BREAK`, which neither test reads. Whether a −1 is
546
+ constructible in `yaml` is **open**, and nothing here depends on the answer, because the trigger
547
+ is the marker and not the mode: **a runtime must not implement this veto behind a
548
+ `mode == "yaml"` test, or any other mode test.** Two modes agreeing on identical characters is
549
+ `modes.md` §7.4's requirement, and a mode conditional here would break it the moment such a
550
+ construction is found.
551
+
552
+ **The run is compared whole, never as a prefix.** `<em>'sure'</em>` mid-sentence has the run
553
+ `sure`, which is not an entry, and pairs as the quotation it is; a prefix test would have matched
554
+ the entry `s` and eaten it. The `outer ∉ ALNUM` test is what makes the run maximal in the
555
+ direction it grows — without it `` `x`'st `` would match `s`.
556
+
557
+ **What was tried and rejected: letting the `MARKER` count as content in the medial-elision
558
+ veto.** The obvious one-line fix — treat `MARKER` as satisfying that veto's `ALNUM` test, on the
559
+ marker-adjacent side or on both — was implemented and measured, and it is wrong. It cannot
560
+ separate a possessive from a quotation that legitimately *begins or ends* at a span boundary,
561
+ which is the shape `modes.md` §3.3 put the marker in `OPENISH` and `CLOSEISH` to support in the
562
+ first place. Its witnesses are the `NARROW` forms of those rows — `<em>'fine'</em>` and
563
+ `'a'<code>x</code>'b'` — and only those: this veto and the medial one are both stated over
564
+ `NARROW`, so the `WIDE` rows `modes.md` §3.3 writes with U+0022 are untouched by either variant
565
+ and are witnesses for a different mistake (removing the marker from the classes outright).
566
+ Measured on polytypo-js: both variants break the released conformance fixture
567
+ `en-us-markdown-commonmark-boundary-nested-quotes` (`*'hi'*` ⟶ `*’hi’*`, where `*‘hi’*` is
568
+ pinned), and the symmetric variant additionally breaks every quotation whose closing mark abuts a
569
+ span — `<em>He said 'hi'</em>`. A clitic list is narrower than the marker: it fires on
570
+ `` `x`'s `` and not on `` `x`'fine' ``, which no test over the marker alone can do, because the
571
+ marker is the same code point in both.
572
+
573
+ **Accepted false positive, recorded rather than tolerated.** A quotation inside an inline span
574
+ whose content is *exactly* a listed fragment is read as an elision:
575
+ `He said <em>'s'</em> loudly.` becomes `He said <em>’s’</em> loudly.`, and `Il dit <em>'l'</em>
576
+ ici.` likewise in `fr`. This is the same exposure, and strictly narrower than, the universal
577
+ medial-`n` veto's accepted `The letter 'n' is common.` — it needs the span boundary as well as
578
+ the single-fragment content. `'x'`, `'no'`, `'fine'` and `'sure thing'` in the same position are
579
+ unaffected; the class is bounded by the lists, which are short and cited. Pinned as fixtures.
580
+
581
+ **What this veto does not close, and why no veto can.** A *plural* possessive after a span —
582
+ `` `xs`' printer `` — has the marker on its left and a space on its right, so its attaching run
583
+ is empty and neither test above applies. That shape is also, code point for code point, a
584
+ quotation's closing mark after a span (`'<em>hello</em>'`, a `modes.md` §3.3 row), so no test
585
+ over these neighbours can separate them. It remains a closing candidate, and because pairing is
586
+ resolved per document it remains able to close a mark far away: with `en-GB`,
587
+
588
+ ```
589
+ A 'stray mark here.
590
+
591
+ Then 'inner' text.
592
+ ```
593
+
594
+ typesets as `A ’stray mark here.` / `Then ‘inner’ text.`, and appending `See `` `x`' `` data.`
595
+ re-pairs the first mark with the new one, making the middle pair nested and changing a line the
596
+ edit did not touch. Measured on 1.3.1 and unchanged by this veto.
597
+
598
+ **This is issue #53 §3, tracked as issue #54, and it stays open.** The report's own trigger lines are
599
+ `` `quotes`' locale data `` and `` `dashes`' own admissibility `` — plural possessives, exactly
600
+ this shape — so the cross-paragraph effect it documents in a real article is *not* repaired by
601
+ spec 1.4.0. Measured on this change, `markdown`/`commonmark`, `en-GB`:
602
+ `It is 'the `` `quotes`' `` locale data' here.` gives
603
+ `It is ‘the `` `quotes`’ `` locale data’ here.` — the plural possessive closes the quotation two
604
+ words early and the author's own closing mark is left to `apostrophe` as a stray U+2019. §1 of
605
+ that issue is closed and §3 is not; the two must not be conflated when the issue is triaged.
606
+
449
607
  **V1 — same-V1-identity adjacency veto** (both widths):
450
608
 
451
609
  > Define `V1ID(c) = U+2019 if c = U+0027, else c` — the **V1 identity** of a code point. `V1ID`
@@ -774,6 +932,11 @@ rule that can falsify it named), per `pipeline-idempotency.md` §5.2.
774
932
 
775
933
  - **[P] A medial apostrophe:** `don't`, `l'été`, `O'Brien`, `Hawai'i`, `1990's` — and their
776
934
  U+2019 forms on a second pass. Vetoed in §3.2.
935
+ - **[P] A possessive or elision written flush against an inline span boundary** (spec 1.4.0):
936
+ `` `x`'s ``, `<code>x</code>'s`, `*x*'s`, `l'<em>idée</em>` — where the attaching fragment is a
937
+ cited `quotes.elisionClitics` entry. Vetoed in §3.2's span-boundary elision veto and converted
938
+ by `apostrophe`. **[P]** rather than **[R]** because the guarantee is the joint outcome of two
939
+ rules: this one declines to pair, `apostrophe` (order 50) emits the U+2019.
777
940
  - **[R] A leading elision that finds no partner** (`'90s`, `'tis`) and **[R] a trailing
778
941
  possessive that finds no partner** (`the dogs' bowls`). Handed to `apostrophe`.
779
942
  - **[R] A foot or inch mark:** `6' 2"`. Both marks are `canClose` only with an empty stack. The
@@ -808,6 +971,13 @@ revision introduces; §5's Corollary A1 explains why no U+2019-specific restrict
808
971
  `spec/fixtures/` pins the verified behaviour across `en-GB`, `en-US`, `fi` and `sv` (§6, rows
809
972
  E1–E4) so the tradeoff is a citable fact rather than an assumption.
810
973
 
974
+ **The same family, reachable through a span boundary, and likewise not closed (spec 1.4.0).** A
975
+ *plural* possessive after a span — `` `xs`' printer `` — is byte-identical to a quotation's
976
+ closing mark after a span, so §3.2's span-boundary elision veto cannot reach it and no test over
977
+ those neighbours could: its attaching fragment is empty on both sides. It stays a `canClose`
978
+ candidate able to close a mark arbitrarily far away, which is item 3's non-locality with a span
979
+ boundary standing in for the elision. §3.2 carries the measured five-line witness.
980
+
811
981
  ---
812
982
 
813
983
  ## 5. Idempotency argument
@@ -824,11 +994,14 @@ can *gain* a capability. Claim 3 rested on both. Three replacements follow.
824
994
  > candidate at any index `i ≠ j`.
825
995
 
826
996
  *Proof.* A candidate's four neighbour reads are compared against `NONE`, `SPACELIKE`, `OPENISH`,
827
- `CLOSEISH`, `DASHISH`, `QUOTEMARK`, `MARKER` and `ALNUM` (the medial veto), and V1 compares
828
- `V1ID` of the relevant code points against `V1ID(g)` (spec 0.4.1). Every `QUOTEMARK` is in
829
- `OPENISH`, in `CLOSEISH`, in `QUOTEMARK`, in none of `SPACELIKE`, `DASHISH`, `ALNUM`, and is
830
- neither `MARKER` nor `NONE`. All seven class tests are therefore **invariant**, not merely
831
- monotone. The skip walks are invariant because `QUOTEMARK ∩ INLINE-SPACE = ∅`.
997
+ `CLOSEISH`, `DASHISH`, `QUOTEMARK`, `MARKER`, `ALNUM` (the medial veto) and `LETTER` (the
998
+ span-boundary veto's run walk, spec 1.4.0), and V1 compares `V1ID` of the relevant code points
999
+ against `V1ID(g)` (spec 0.4.1). Every `QUOTEMARK` is in `OPENISH`, in `CLOSEISH`, in
1000
+ `QUOTEMARK`, in none of `SPACELIKE`, `DASHISH`, `ALNUM`, `LETTER`, and is neither `MARKER` nor
1001
+ `NONE`. All eight class tests are therefore **invariant**, not merely monotone. The skip walks
1002
+ are invariant because `QUOTEMARK ∩ INLINE-SPACE = ∅`, and the span-boundary veto's `LETTER` runs
1003
+ are invariant for the same reason — no quote glyph is a `LETTER`, so substituting one can neither
1004
+ extend nor truncate a run, nor change the `outer ∉ ALNUM` test that bounds it.
832
1005
 
833
1006
  **V1 is invariant too, as of spec 0.4.1, and this required changing V1 itself, not just arguing
834
1007
  about it.** Before 0.4.1, V1 compared raw code points, and replacing a neighbour's `U+0027` with
@@ -871,7 +1044,10 @@ by the identical literal `rock`/`n`/`roll` context — the veto fires identicall
871
1044
  vetoed again, `quotes` makes no pairing, and `apostrophe` does not act on U+2019 at all (§4).
872
1045
  The construction is a fixed point (§6 row P4 pins it).
873
1046
 
874
- **Modes.** `text` is the base case above. In `html` and `markdown`, `modes.md` §3.2's
1047
+ **Modes.** `text` is the base case above — and for the span-boundary veto that base case is
1048
+ vacuous, since `text` produces no marker at all and neither of its two tests can fire there. For
1049
+ `yaml` see §3.2: the veto is marker-triggered rather than mode-triggered, so it needs no separate
1050
+ argument and must not be given a mode conditional. In `html` and `markdown`, `modes.md` §3.2's
875
1051
  concatenation-with-marker model means the veto's bounded lookaround can land on a `MARKER` (a
876
1052
  negative integer, in none of `LETTER`, `INLINE-SPACE`, `NARROW`) exactly where a real code
877
1053
  point would otherwise be — a context word split from its mark by an element or span boundary
@@ -888,7 +1064,12 @@ rest of this rule's determinism.
888
1064
  > *neighbour*, Lemma A applies — **including its V1 clause**, now that V1 itself compares `V1ID`
889
1065
  > rather than raw code points. As the mark *itself*: both `U+0027` and `U+2019` are in `NARROW`,
890
1066
  > so the stack partition is unchanged; the medial veto is stated over `NARROW`, so its verdict is
891
- > unchanged; neither is in `SPACE-RIGHT`/`SPACE-LEFT`, by constraint **Q-A**; and `V1ID` maps
1067
+ > unchanged; the span-boundary veto (spec 1.4.0) is stated over `NARROW` too and reads only the
1068
+ > `LETTER` run beyond the mark and the `MARKER` beside it, neither of which `apostrophe` touches,
1069
+ > so its verdict is unchanged — which is exactly what makes the fix it performs a fixed point: a
1070
+ > vetoed mark survives pass 2 as U+0027, `apostrophe` emits U+2019 in its place (case 4 across a
1071
+ > `MARKER` on the left, case 3 across one on the right, both already specified), U+2019 is in
1072
+ > `NARROW`, and the next pass vetoes the same index again; neither is in `SPACE-RIGHT`/`SPACE-LEFT`, by constraint **Q-A**; and `V1ID` maps
892
1073
  > both to the same value, so V1's verdict on the mark's own candidacy is unchanged too. Every
893
1074
  > verdict is identical. This is a genuine **CO-S** discharge in the sense of
894
1075
  > `pipeline-idempotency.md` §5.1a — `E(apostrophe) = {U+2019}` is wholly inert for this rule,
@@ -1138,6 +1319,22 @@ reason as N1–N5 above; they are verified against `spec/fixtures/en-US.json`.
1138
1319
  | H2 | `html` | `<p>rock 'n' <em>roll</em></p>` | `<p>rock ’n’ <em>roll</em></p>` | mirror case on `right` |
1139
1320
  | H3 | `markdown` (`commonmark`) | `*rock* 'n' roll\n` | `*rock* ’n’ roll\n` | `left` word is inside an emphasis span; same reasoning as H1, and the result matches `text` mode on the same characters split the same way |
1140
1321
 
1322
+ #### Span-boundary elision veto — `html`, `markdown` (spec 1.4.0)
1323
+
1324
+ Every row is measured output, and each is pinned as a conformance fixture; the `Why` column is
1325
+ what a port should be able to re-derive from §3.2 alone.
1326
+
1327
+ | # | Locale | Input | Output | Why |
1328
+ | --- | --- | --- | --- | --- |
1329
+ | S1 | `en-GB` | `` He says 'avoid `x`'s printer.' Done. `` | `` He says ‘avoid `x`’s printer.’ Done. `` | `Llit` is the `MARKER`, the run right of the mark is `s`, and `s` is a cited `after` entry. Vetoed; `apostrophe` case 4 emits U+2019; the author's own pair keeps the primary glyphs. Through 1.3.1 this gave `` ’avoid `x`‘s printer.’ `` |
1330
+ | S2 | `fr` | `Il dit 'l'<em>idée</em> est bonne.' Fin.` | `Il dit «⍽l’<em>idée</em> est bonne.⍽» Fin.` | the mirror direction: `Rlit` is the `MARKER`, the run left of the mark is `l`, a cited `before` entry. Through 1.3.1 this gave `Il dit «⍽l⍽»<em>idée</em> est bonne.' Fin.` — guillemets around one letter, the real closing mark abandoned |
1331
+ | S3 | `fr` | `Il dit 'jusqu'<em>ici</em> tout va bien.' Fin.` | `Il dit «⍽jusqu’<em>ici</em> tout va bien.⍽» Fin.` | the run is compared **whole**: it is `jusqu`, so an entry `qu` could not match it. This is why `jusqu`, `lorsqu`, `puisqu` and `quoiqu` are listed in their own right |
1332
+ | S4 | `fr` | `Il dit <em>'oui'</em> ici.` | `Il dit <em>«⍽oui⍽»</em> ici.` | the negative control the mechanism exists to preserve. `Llit` is the `MARKER` here too — what separates this from S2 is only that `oui` is not a listed fragment |
1333
+ | S5 | `en-GB` | `` He says 'avoid `xs`' printer.' Done. `` | `` He says ‘avoid `xs`’ printer.' Done. `` | **not closed.** The plural possessive's run is empty, so neither test applies, and these code points are also a closing mark after a span. The second mark takes the pairing, the third is abandoned as U+0027. §4, §7 item 10, issue #54 |
1334
+ | S6 | `fr` | `Il dit 'QU'<em>il</em> vienne.' Fin.` | `Il dit «⍽QU⍽»<em>il</em> vienne.' Fin.` | **not closed.** The fold reaches only the run's first code point, and only ASCII `A`–`Z`, so `QU` does not match `qu` and the inversion survives. Same limit as the listed veto's context words |
1335
+ | S7 | `en-US` | `He said <em>'s'</em> loudly.` | `He said <em>’s’</em> loudly.` | the accepted false positive: a quotation inside a span whose whole content is a listed fragment. Narrower than the medial-`n` veto's own accepted `The letter 'n' is common.`, since it needs the boundary as well |
1336
+ | S8 | `nl` | `Hij zegt 'ik kom <em>vroeg </em>'s avonds terug.' Klaar.` | `Hij zegt “ik kom <em>vroeg </em>’s avonds terug.” Klaar.` | `'s` is a word-*initial* omission, so its run lies to the mark's right and the entry is an `after` one — the clearest case for reading both lists positionally (§2) |
1337
+
1141
1338
  ### `en-GB` — primary `‘ ’`, secondary `“ ”`
1142
1339
 
1143
1340
  | # | Input | Output | Why |
@@ -1276,6 +1473,19 @@ Ordered by how much this matters.
1276
1473
  text. §6's dedicated adversarial sweep found no failures, but the protection that remains is
1277
1474
  the vetoes and the gate, not an independent structural guarantee.
1278
1475
 
1476
+ 10. **The span-boundary defect is closed for a cited fragment and open for everything else
1477
+ (spec 1.4.0).** §3.2's span-boundary elision veto reads `quotes.elisionClitics`, so a locale
1478
+ with empty lists is classified exactly as 1.3.1 classified it — issue #53's inversion is
1479
+ still reachable there, and the fix for that locale is a cited entry, not a change to this
1480
+ rule. Two shapes stay open in **every** locale, both for the same reason: the attaching
1481
+ fragment is not there to read. A plural possessive after a span (`` `xs`' ``) has an empty
1482
+ run and is byte-identical to a closing mark after a span (§4) — **this is issue #53 §3, the
1483
+ cross-paragraph witness that motivated the report; it is open and tracked as issue #54**; and a fragment written in
1484
+ capitals (`QU'<em>il</em>`) fails the first-code-point folding this rule shares with item 8's
1485
+ listed veto. Neither is a candidate for a wider mechanism: widening the folding is
1486
+ `ARCHITECTURE.md` §4.4's banned territory, and the plural possessive has no local evidence at
1487
+ all.
1488
+
1279
1489
  ---
1280
1490
 
1281
1491
  ## History
@@ -1322,3 +1532,18 @@ every locale. Three things change together, and none of them works without the o
1322
1532
  The cost accepted, knowingly and with §6's N1/N2 rows kept as its permanent witnesses, is that a
1323
1533
  genuine quotation of the letter *n* is elided. §7 item 8 records why that is the smaller error
1324
1534
  than the class 0.5.0 traded it for.
1535
+
1536
+ 1.4.0 adds the **span-boundary elision veto** (§2, §3.2): `quotes.elisionClitics`, two
1537
+ locale-data lists of the word fragments that attach across an elision or possessive apostrophe,
1538
+ consulted only when the mark is flush against an inline span boundary. It closes **issue #53
1539
+ §1** — a possessive or elision written against a span (`` `x`'s ``, `l'<em>idée</em>`) was
1540
+ classified as a quotation candidate, took the pairing from the author's own mark inside a
1541
+ quotation, and inverted the pair in every locale. **It does not close issue #53 §3**, the
1542
+ cross-paragraph damage that motivated the report, which is tracked separately as issue #54: that witness is a *plural* possessive after a
1543
+ span, and §3.2 records why no veto over these neighbours can reach it. The simpler design — letting the boundary marker satisfy the
1544
+ medial-elision veto's `ALNUM` test — was implemented, measured, and rejected before any release:
1545
+ it breaks a quotation that legitimately begins or ends at a span boundary, including the
1546
+ released fixture `en-us-markdown-commonmark-boundary-nested-quotes` and the `NARROW` forms of
1547
+ `modes.md` §3.3's normative rows — not their `WIDE` forms, which both vetoes leave alone. §3.2 records that measurement, the accepted false positive (a quotation whose
1548
+ whole content is one listed fragment), and the one shape no veto over these neighbours can
1549
+ reach — the plural possessive, which is byte-identical to a closing mark after a span.
@@ -1,7 +1,8 @@
1
1
  # Rule: `symbols`
2
2
 
3
3
  **Order:** 60. **Default:** on. **Modes:** text, html, markdown, yaml.
4
- **Spec version:** 0.1.0.
4
+ **Spec version:** 1.5.0 (0.1.0 for everything except §5's `I₆` discharge against `apostrophe`'s
5
+ `CLOSEDELIM` (1.5.0) — which adds no step and changes nothing this rule computes).
5
6
 
6
7
  ---
7
8
 
@@ -352,10 +353,29 @@ class, so no verdict moves.
352
353
  points and the emitted signs are not in `WORDISH`, so no listed hyphen form's word boundary
353
354
  changes.
354
355
 
355
- **Against `I₅`/`I₆` (`quotes`, `apostrophe`).** Discharged: this rule emits nothing in
356
- `STRAIGHT`, and the signs it does emit are not in `SPACELIKE` or `ALNUM`, so a surviving
357
- straight mark adjacent to one keeps every capability it had — and by `quotes` Claim 3, which
358
- needs only that capabilities do not _increase_, that is sufficient.
356
+ **Against `I₅`/`I₆` (`quotes`, `apostrophe`).** Discharged, in two halves — the emission and the
357
+ **deletion** — because this rule is the only one after `apostrophe` that shortens a span.
358
+
359
+ *The emission.* This rule emits nothing in `STRAIGHT`, and the signs it does emit (U+00A9,
360
+ U+00AE, U+2122, U+00D7) are in none of `apostrophe`'s classes and in neither `SPACELIKE` nor
361
+ `ALNUM`, so a surviving straight mark adjacent to one keeps every capability it had. For `quotes`
362
+ the discharge is `quotes.md` §5's **Lemma A and Corollary A1**, which are stated
363
+ position-universally over `QUOTEMARK` substitution; the pre-0.4.1 appeal to "`quotes` Claim 3,
364
+ which needs only that capabilities do not _increase_" is withdrawn — Claim 3 rested on Claims 1
365
+ and 2, which `quotes.md` §0's mandate 1 withdrew, and `apostrophe.md` §5 records that citing it
366
+ is itself a latent defect.
367
+
368
+ *The deletion (spec 1.5.0).* §3.2 step 6 replaces `(c)`, `(r)` and `(tm)` with one code point,
369
+ which **deletes a U+0029** from the text. Through spec 1.4.0 that was invisible to `apostrophe`,
370
+ because U+0029 was in no class its ladder read on a mark's left. From 1.5.0 it is a `CLOSEDELIM`
371
+ member (`apostrophe.md` §3.1), so the deletion removes a code point from a position the next pass
372
+ reads. It is discharged in the candidacy-**removing** direction and cannot flip a verdict:
373
+ a U+0027 that survived this pass with `)` immediately on its left did **not** convert, which by
374
+ case 2a means its right neighbour was not `ALNUM`; after the replacement its left neighbour is
375
+ the emitted sign, in no class at all, so it does not convert on the next pass either. `(c)'.`
376
+ gives `©'.` and holds. The reverse — a mark that converted this pass — is already U+2019 and is
377
+ not a candidate again (`apostrophe.md` §5). If this rule ever emits a bracket or a quotation
378
+ glyph, or deletes one from the *right* of a mark, this paragraph must be re-derived.
359
379
 
360
380
  ---
361
381
 
@@ -9,7 +9,7 @@ require_relative "../registry"
9
9
  module Polytypo
10
10
  module Engine
11
11
  module Rules
12
- # spec/rules/apostrophe.md (spec 1.2.0), order 50.
12
+ # spec/rules/apostrophe.md (spec 1.5.0), order 50.
13
13
  #
14
14
  # Converts a straight U+0027 to U+2019 where it is genuinely an apostrophe: a contraction,
15
15
  # an elision, a possessive, or a decade elision. Runs immediately after `quotes` (order 40)
@@ -51,6 +51,14 @@ module Polytypo
51
51
  # without its brackets, its dashes and Engine::MARKER. Read only by case 3a.
52
52
  OPENQUOTE = [0xAB, 0x2018, 0x201A, 0x201B, 0x201C, 0x201E, 0x201F, 0x2039].freeze
53
53
 
54
+ # CLOSEDELIM is apostrophe.md 3.1 CLOSEDELIM (spec 1.5.0): the bracket and quotation
55
+ # members of CLOSEISH, without its sentence punctuation and without the dashes OPENISH
56
+ # already carries. These are exactly the closing delimiters case 3 has always accepted on
57
+ # the mark's RIGHT; before 1.5.0 no left-hand test accepted any of them. Engine::MARKER is
58
+ # not a member (modes.md 3.3): it is in OPENISH, so a mark against a span boundary already
59
+ # reaches case 4 and emits the same U+2019. Read only by case 2a.
60
+ CLOSEDELIM = [0x29, 0x5D, 0x7D, 0xBB, 0x2019, 0x201D, 0x203A].freeze
61
+
54
62
  BREAK = [0x0A, 0x0D, 0x0B, 0x0C, 0x85, 0x2028, 0x2029, Engine::LINE_MARKER].freeze
55
63
 
56
64
  # SPACELIKE, including Engine::LINE_MARKER as a member of BREAK for every rule everywhere
@@ -78,6 +86,11 @@ module Polytypo
78
86
  # Case 2 -- medial apostrophe: `don't`, `l'été`, `O'Brien`, `1990's`.
79
87
  return true if alnum?(left) && alnum?(right)
80
88
 
89
+ # Case 2a (spec 1.5.0) -- suffix or possessive after a closing delimiter:
90
+ # `(order 90)'s`, `“Hamlet”'s`, `{user}'s`. Disjoint from every other case, so its
91
+ # position in the ladder carries no behaviour.
92
+ return true if CLOSEDELIM.include?(left) && alnum?(right)
93
+
81
94
  # Case 3 -- trailing elision or possessive: `the dogs' bowls`, `Jesus'`, `rock 'n'` (the
82
95
  # trailing mark).
83
96
  if left != Engine::NONE && UnicodeUtil.letter?(left) &&
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Polytypo
4
- VERSION = "1.4.0"
4
+ VERSION = "1.5.0"
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: polytypo
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.4.0
4
+ version: 1.5.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Iurii Rogulia