polytypo 1.17.5 → 1.18.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 (42) hide show
  1. checksums.yaml +4 -4
  2. data/lib/polytypo/data/VERSION +1 -1
  3. data/lib/polytypo/data/fixtures/bg.json +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 +1 -1
  9. data/lib/polytypo/data/fixtures/en-US.json +1 -1
  10. data/lib/polytypo/data/fixtures/es.json +272 -7
  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 +1 -1
  14. data/lib/polytypo/data/fixtures/hr.json +1 -1
  15. data/lib/polytypo/data/fixtures/hu.json +1 -1
  16. data/lib/polytypo/data/fixtures/it.json +1 -1
  17. data/lib/polytypo/data/fixtures/locale-resolution.json +1 -1
  18. data/lib/polytypo/data/fixtures/lt.json +1 -1
  19. data/lib/polytypo/data/fixtures/nl.json +1 -1
  20. data/lib/polytypo/data/fixtures/pl.json +1 -1
  21. data/lib/polytypo/data/fixtures/pt-BR.json +1 -1
  22. data/lib/polytypo/data/fixtures/pt-PT.json +1 -1
  23. data/lib/polytypo/data/fixtures/ro.json +1 -1
  24. data/lib/polytypo/data/fixtures/ru.json +1 -1
  25. data/lib/polytypo/data/fixtures/sk.json +1 -1
  26. data/lib/polytypo/data/fixtures/sl.json +1 -1
  27. data/lib/polytypo/data/fixtures/sr.json +1 -1
  28. data/lib/polytypo/data/fixtures/sv.json +1 -1
  29. data/lib/polytypo/data/fixtures/tr.json +1 -1
  30. data/lib/polytypo/data/fixtures/uk.json +1 -1
  31. data/lib/polytypo/data/locales/el.json +1 -1
  32. data/lib/polytypo/data/locales/es.json +2 -2
  33. data/lib/polytypo/data/locales/registry.json +1 -1
  34. data/lib/polytypo/data/rules/dashes.md +108 -16
  35. data/lib/polytypo/data/rules/order.json +2 -2
  36. data/lib/polytypo/data/rules/pipeline-idempotency.md +7 -0
  37. data/lib/polytypo/data/schema/locale.schema.json +2 -2
  38. data/lib/polytypo/engine/rules/dash_shared.rb +32 -21
  39. data/lib/polytypo/engine/rules/dashes.rb +4 -1
  40. data/lib/polytypo/engine/rules/dashes_paired.rb +160 -0
  41. data/lib/polytypo/version.rb +1 -1
  42. metadata +2 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 1ada78393268412e145c286f3a52ca2b1529637472b413e2ad96533763ca4feb
4
- data.tar.gz: ca2eab4d74188ca6f57e71a573722e8b51454621e2d648fcd4df6417d4a2701b
3
+ metadata.gz: b78196aba8f371ed14077dbbb0a1642276cf567628d2fc4dfc9c467e2f543f02
4
+ data.tar.gz: e9a2b2df572deb0eaf529813d63dc8d72f378e06c541f8567f81b1451219e557
5
5
  SHA512:
6
- metadata.gz: 72da361c3230aa58f7c06cb595685d53e6a949858eae318ae207fc5819924c598d99246393afdc917c3abe1431a6cbe88d548133e758668ef3d23ad382bfc6f2
7
- data.tar.gz: b7b2f9646e6f326010604a6097409d34e3c80b3ffabf4a28582946832c4705da343437bd538188363b32b97393889f6ed204d06de47cf315623e80765e61830e
6
+ metadata.gz: 92a5ca301117e7baa8647b1b0148d1e2980c506a3efda57fbd36b973fdcb4dae31c7e011e53973dd39954539e12090651860bff1de4b11223bc11bbc401c873f
7
+ data.tar.gz: 534cc7cedb87c00a83cee59e2cba5a6fe396b8c5b8bc512ccc5576ac5c271286cad15af3e10c53e40d9c452a594236202c2894f2cfde8edd9efe6ec7bf1f91c0
@@ -1 +1 @@
1
- 1.17.5
1
+ 1.18.0
@@ -1,5 +1,5 @@
1
1
  {
2
- "spec": "1.17.5",
2
+ "spec": "1.18.0",
3
3
  "locale": "bg",
4
4
  "cases": [
5
5
  {
@@ -1,5 +1,5 @@
1
1
  {
2
- "spec": "1.17.5",
2
+ "spec": "1.18.0",
3
3
  "locale": "cs",
4
4
  "cases": [
5
5
  {
@@ -1,5 +1,5 @@
1
1
  {
2
- "spec": "1.17.5",
2
+ "spec": "1.18.0",
3
3
  "locale": "de-CH",
4
4
  "cases": [
5
5
  {
@@ -1,5 +1,5 @@
1
1
  {
2
- "spec": "1.17.5",
2
+ "spec": "1.18.0",
3
3
  "locale": "de-DE",
4
4
  "cases": [
5
5
  {
@@ -1,5 +1,5 @@
1
1
  {
2
- "spec": "1.17.5",
2
+ "spec": "1.18.0",
3
3
  "locale": "el",
4
4
  "cases": [
5
5
  {
@@ -1,5 +1,5 @@
1
1
  {
2
- "spec": "1.17.5",
2
+ "spec": "1.18.0",
3
3
  "locale": "en-GB",
4
4
  "cases": [
5
5
  {
@@ -1,5 +1,5 @@
1
1
  {
2
- "spec": "1.17.5",
2
+ "spec": "1.18.0",
3
3
  "locale": "en-US",
4
4
  "cases": [
5
5
  {
@@ -1,5 +1,5 @@
1
1
  {
2
- "spec": "1.17.5",
2
+ "spec": "1.18.0",
3
3
  "locale": "es",
4
4
  "cases": [
5
5
  {
@@ -43,20 +43,285 @@
43
43
  "note": "abbreviatedAfterTerminal = false: la forma de dos puntos tras «?» que usa el ruso no existe en español, así que los dos puntos se completan a U+2026 en vez de conservarse."
44
44
  },
45
45
  {
46
- "id": "es-dashes-parenthetical-em-spaced-known-deviation",
46
+ "id": "es-dashes-paired-spaced-hyphens",
47
47
  "rule": "dashes",
48
48
  "mode": "text",
49
49
  "in": "El plan - si existe - fracasa.",
50
- "out": "El plan — si existe — fracasa.",
51
- "note": "dash.parenthetical = \"em-spaced\", una aproximación fijada por decisión del operador (spec 1.17.1). El DPD prescribe raya con espacios FUERA del par y ninguno dentro («El plan —si existe— fracasa.»); el enum no expresa esa asimetría, así que la salida lleva dos espacios interiores que la norma no admite. Se prefiere a dejar el guion U+002D del autor. Véase la voz dashes de es.json."
50
+ "out": "El plan —si existe— fracasa.",
51
+ "note": "Guiones espaciados a ambos lados: el par se escribe «—si existe—», con espacio fuera del par y ninguno dentro (DPD «raya», 2)."
52
52
  },
53
53
  {
54
- "id": "es-dashes-double-hyphen-em-spaced-known-deviation",
54
+ "id": "es-dashes-paired-double-hyphen",
55
55
  "rule": "dashes",
56
56
  "mode": "text",
57
57
  "in": "Caminamos -- casi 3 km -- y estaba cerrada.",
58
- "out": "Caminamos — casi 3 km — y estaba cerrada.",
59
- "note": "Misma aproximación que es-dashes-parenthetical-em-spaced-known-deviation, con la forma «--» que se teclea a menudo; los espacios interiores no son los del DPD."
58
+ "out": "Caminamos —casi 3 km— y estaba cerrada.",
59
+ "note": "La forma «--» que se teclea a menudo converge a la misma forma del DPD."
60
+ },
61
+ {
62
+ "id": "es-dashes-paired-tight-em",
63
+ "rule": "dashes",
64
+ "mode": "text",
65
+ "in": "El plan—si existe—fracasa.",
66
+ "out": "El plan —si existe— fracasa.",
67
+ "note": "Rayas pegadas por ambos lados: se añade el espacio exterior y no se toca el interior."
68
+ },
69
+ {
70
+ "id": "es-dashes-paired-already-correct",
71
+ "rule": "dashes",
72
+ "mode": "text",
73
+ "in": "Llegó —por fin— a casa.",
74
+ "out": "Llegó —por fin— a casa.",
75
+ "note": "Forma del DPD ya correcta: punto fijo."
76
+ },
77
+ {
78
+ "id": "es-dashes-paired-spaced-em",
79
+ "rule": "dashes",
80
+ "mode": "text",
81
+ "in": "Llegó — por fin — a casa.",
82
+ "out": "Llegó —por fin— a casa.",
83
+ "note": "Salida de las versiones 1.17.x (espacios interiores): se cierran los interiores."
84
+ },
85
+ {
86
+ "id": "es-dashes-paired-half-correct",
87
+ "rule": "dashes",
88
+ "mode": "text",
89
+ "in": "Llegó —por fin — a casa.",
90
+ "out": "Llegó —por fin— a casa.",
91
+ "note": "Raya de cierre con espacio interior: se cierra."
92
+ },
93
+ {
94
+ "id": "es-dashes-paired-before-full-stop",
95
+ "rule": "dashes",
96
+ "mode": "text",
97
+ "in": "Llegó a casa--por fin--.",
98
+ "out": "Llegó a casa —por fin—.",
99
+ "note": "El cierre queda pegado al punto: no se inserta espacio donde `spaces` lo borraría."
100
+ },
101
+ {
102
+ "id": "es-dashes-paired-guillemets",
103
+ "rule": "dashes",
104
+ "mode": "text",
105
+ "in": "«Llegó - por fin - a casa»",
106
+ "out": "«Llegó —por fin— a casa»",
107
+ "note": "Las comillas angulares no cambian el reparto del par."
108
+ },
109
+ {
110
+ "id": "es-dashes-paired-compound-inside",
111
+ "rule": "dashes",
112
+ "mode": "text",
113
+ "in": "Llegó - por fin - a la zona hispano-árabe.",
114
+ "out": "Llegó —por fin— a la zona hispano-árabe.",
115
+ "note": "Un guion de palabra compuesta dentro del par no cuenta como raya."
116
+ },
117
+ {
118
+ "id": "es-dashes-paired-digit-inside",
119
+ "rule": "dashes",
120
+ "mode": "text",
121
+ "in": "Llegó - tras 3 - a casa.",
122
+ "out": "Llegó —tras 3— a casa.",
123
+ "note": "Un número dentro del par no impide la conversión."
124
+ },
125
+ {
126
+ "id": "es-dashes-paired-range-inside",
127
+ "rule": "dashes",
128
+ "mode": "text",
129
+ "in": "Llegó - tras 3-5 - a casa.",
130
+ "out": "Llegó —tras 3-5— a casa.",
131
+ "note": "Un rango con guion dentro del par queda como está (lo gestiona `ranges`)."
132
+ },
133
+ {
134
+ "id": "es-dashes-dialogue-untouched",
135
+ "rule": "dashes",
136
+ "mode": "text",
137
+ "in": "—Hola —dijo—. ¿Vienes? —preguntó María.",
138
+ "out": "—Hola —dijo—. ¿Vienes? —preguntó María.",
139
+ "note": "Las rayas de diálogo no se tocan: la primera abre la línea y el segmento tiene más de dos rayas."
140
+ },
141
+ {
142
+ "id": "es-dashes-dialogue-open",
143
+ "rule": "dashes",
144
+ "mode": "text",
145
+ "in": "—Hola —dijo.",
146
+ "out": "—Hola —dijo.",
147
+ "note": "Diálogo con una sola raya de incisa: se deja como está."
148
+ },
149
+ {
150
+ "id": "es-dashes-dialogue-hyphens",
151
+ "rule": "dashes",
152
+ "mode": "text",
153
+ "in": "—Hola - dijo - y se fue.",
154
+ "out": "—Hola - dijo - y se fue.",
155
+ "note": "Una raya de diálogo al inicio hace que el segmento tenga tres rayas: ninguna se convierte."
156
+ },
157
+ {
158
+ "id": "es-dashes-single-hyphen-untouched",
159
+ "rule": "dashes",
160
+ "mode": "text",
161
+ "in": "Madrid - la capital de España.",
162
+ "out": "Madrid - la capital de España.",
163
+ "note": "Un solo guion espaciado no forma par: se deja tal como está (el DPD no describe este uso)."
164
+ },
165
+ {
166
+ "id": "es-dashes-two-sentences-untouched",
167
+ "rule": "dashes",
168
+ "mode": "text",
169
+ "in": "Madrid - la capital. Sevilla - la bella.",
170
+ "out": "Madrid - la capital. Sevilla - la bella.",
171
+ "note": "Cada frase tiene una sola raya: no hay par y no se toca ninguna."
172
+ },
173
+ {
174
+ "id": "es-dashes-three-untouched",
175
+ "rule": "dashes",
176
+ "mode": "text",
177
+ "in": "Juan - mi primo - llegó y - según dicen - durmió.",
178
+ "out": "Juan - mi primo - llegó y - según dicen - durmió.",
179
+ "note": "Cuatro rayas en una frase: no se empareja ninguna."
180
+ },
181
+ {
182
+ "id": "es-dashes-across-line-untouched",
183
+ "rule": "dashes",
184
+ "mode": "text",
185
+ "in": "Llegó - por\nfin - a casa.",
186
+ "out": "Llegó - por\nfin - a casa.",
187
+ "note": "El par no cruza un salto de línea."
188
+ },
189
+ {
190
+ "id": "es-dashes-affix-hyphens-untouched",
191
+ "rule": "dashes",
192
+ "mode": "text",
193
+ "in": "el sufijo -mente y el prefijo pre- son",
194
+ "out": "el sufijo -mente y el prefijo pre- son",
195
+ "note": "Guiones de afijo, pegados por un solo lado, no son rayas."
196
+ },
197
+ {
198
+ "id": "es-dashes-option-flag-untouched",
199
+ "rule": "dashes",
200
+ "mode": "text",
201
+ "in": "usa --force y -v",
202
+ "out": "usa --force y -v",
203
+ "note": "Opciones de línea de comandos: no son rayas."
204
+ },
205
+ {
206
+ "id": "es-dashes-misplaced-spacing-untouched",
207
+ "rule": "dashes",
208
+ "mode": "text",
209
+ "in": "Llegó— por fin —a casa.",
210
+ "out": "Llegó— por fin —a casa.",
211
+ "note": "Espacios invertidos respecto a la forma del DPD: no se reinterpretan."
212
+ },
213
+ {
214
+ "id": "es-dashes-question-inside-paired",
215
+ "rule": "dashes",
216
+ "mode": "text",
217
+ "in": "Llegó —¿quién lo diría?— a casa.",
218
+ "out": "Llegó —¿quién lo diría?— a casa.",
219
+ "note": "El signo de interrogación dentro del par no corta la frase."
220
+ },
221
+ {
222
+ "id": "es-dashes-abbreviation-splits-pair",
223
+ "rule": "dashes",
224
+ "mode": "text",
225
+ "in": "Llegó - p. ej. ayer - a casa.",
226
+ "out": "Llegó - p. ej. ayer - a casa.",
227
+ "note": "El punto de la abreviatura cierra la frase: cada mitad tiene una sola raya, no hay par y no se toca ninguna (el espacio duro U+00A0 tras «p.» lo pone nbsp)."
228
+ },
229
+ {
230
+ "id": "es-dashes-lone-en-dash-untouched",
231
+ "rule": "dashes",
232
+ "mode": "text",
233
+ "in": "Llegó – por fin – a casa.",
234
+ "out": "Llegó – por fin – a casa.",
235
+ "note": "P5 de dashes.md: una semirraya sola escrita por el autor no se reinterpreta."
236
+ },
237
+ {
238
+ "id": "es-dashes-digit-far-dash-untouched",
239
+ "rule": "dashes",
240
+ "mode": "text",
241
+ "in": "Vimos 3 - 4--por fin--bien.",
242
+ "out": "Vimos 3 - 4--por fin--bien.",
243
+ "note": "T1 de dashes.md: no se inserta un espacio junto a una cifra que tiene otra raya al otro lado."
244
+ },
245
+ {
246
+ "id": "es-dashes-roman-range-before-pair",
247
+ "rule": "dashes",
248
+ "mode": "text",
249
+ "in": "En los siglos XV—XVII - según dicen - hubo guerras.",
250
+ "out": "En los siglos XV—XVII - según dicen - hubo guerras.",
251
+ "note": "Un rango en cifras romanas cuenta como raya del segmento: tres rayas, no se toca ninguna."
252
+ },
253
+ {
254
+ "id": "es-dashes-abbreviation-title-splits-pair",
255
+ "rule": "dashes",
256
+ "mode": "text",
257
+ "in": "Llegó - dijo el Sr. Pérez - a casa.",
258
+ "out": "Llegó - dijo el Sr. Pérez - a casa.",
259
+ "note": "El punto de «Sr.» cierra la frase aunque `nbsp` ponga después un espacio duro: cada mitad tiene una sola raya y no se toca ninguna. Debe dar lo mismo en la segunda pasada."
260
+ },
261
+ {
262
+ "id": "es-dashes-plus-minus-outside-pair",
263
+ "rule": "dashes",
264
+ "mode": "text",
265
+ "in": "Llegó - con +/- 5 kg - a casa.",
266
+ "out": "Llegó —con ± 5 kg— a casa.",
267
+ "note": "El guion de «+/-» no es una raya: `symbols` lo convierte en «±» después, de modo que no debe contar para el par."
268
+ },
269
+ {
270
+ "id": "es-dashes-roman-range-beside-single",
271
+ "rule": "dashes",
272
+ "mode": "text",
273
+ "in": "Era XV—XVII - dicen.",
274
+ "out": "Era XV—XVII - dicen.",
275
+ "note": "P4: la raya pegada entre dos números romanos es un rango ya correcto y cuenta como raya del segmento; con la otra son dos y no se convierte ninguna."
276
+ },
277
+ {
278
+ "id": "es-dashes-digit-inside-pair-cluster",
279
+ "rule": "dashes",
280
+ "mode": "text",
281
+ "in": "Salió - 5 - de casa.",
282
+ "out": "Salió —5— de casa.",
283
+ "note": "El número solo dentro del par convierte; en la segunda pasada las dos rayas forman un grupo con la cifra y se rechazan, de modo que el resultado no cambia."
284
+ },
285
+ {
286
+ "id": "es-dashes-nbsp-beside-stroke-untouched",
287
+ "rule": "dashes",
288
+ "mode": "text",
289
+ "in": "Llegó - por fin - a casa.",
290
+ "out": "Llegó - por fin - a casa.",
291
+ "note": "Un espacio duro junto a una raya la deja fuera: el segmento queda como está."
292
+ },
293
+ {
294
+ "id": "es-dashes-asymmetric-range-inside-pair",
295
+ "rule": "dashes",
296
+ "mode": "text",
297
+ "in": "Llegó - a 5 -10 km - a casa.",
298
+ "out": "Llegó —a 5 -10 km— a casa.",
299
+ "note": "Un rango de cifras escrito con espacio solo a un lado es un candidato de `ranges`, sea simétrico o no, y no cuenta como raya."
300
+ },
301
+ {
302
+ "id": "es-dashes-question-space-splits-pair",
303
+ "rule": "dashes",
304
+ "mode": "text",
305
+ "in": "Llegó - ¿cómo? ya - a casa.",
306
+ "out": "Llegó - ¿cómo? ya - a casa.",
307
+ "note": "Un «?» seguido de espacio cierra la frase: cada mitad tiene una sola raya."
308
+ },
309
+ {
310
+ "id": "es-dashes-marker-splits-pair-html",
311
+ "rule": "dashes",
312
+ "mode": "html",
313
+ "in": "<p>Llegó - ayer.<em>Hoy</em> - a casa.</p>",
314
+ "out": "<p>Llegó - ayer.<em>Hoy</em> - a casa.</p>",
315
+ "note": "Un punto antes de un límite de marcado cierra la frase: cada mitad tiene una sola raya."
316
+ },
317
+ {
318
+ "id": "es-dashes-marker-splits-pair-markdown",
319
+ "rule": "dashes",
320
+ "mode": "markdown",
321
+ "in": "Llegó - ayer.*Hoy* - a casa.",
322
+ "out": "Llegó - ayer.*Hoy* - a casa.",
323
+ "note": "Un punto antes de un límite de marcado cierra la frase: cada mitad tiene una sola raya.",
324
+ "dialect": "commonmark"
60
325
  },
61
326
  {
62
327
  "id": "es-dashes-compound-hyphen",
@@ -1,5 +1,5 @@
1
1
  {
2
- "spec": "1.17.5",
2
+ "spec": "1.18.0",
3
3
  "locale": "fi",
4
4
  "cases": [
5
5
  {
@@ -1,5 +1,5 @@
1
1
  {
2
- "spec": "1.17.5",
2
+ "spec": "1.18.0",
3
3
  "locale": "fr-CA",
4
4
  "cases": [
5
5
  {
@@ -1,5 +1,5 @@
1
1
  {
2
- "spec": "1.17.5",
2
+ "spec": "1.18.0",
3
3
  "locale": "fr",
4
4
  "cases": [
5
5
  {
@@ -1,5 +1,5 @@
1
1
  {
2
- "spec": "1.17.5",
2
+ "spec": "1.18.0",
3
3
  "locale": "hr",
4
4
  "cases": [
5
5
  {
@@ -1,5 +1,5 @@
1
1
  {
2
- "spec": "1.17.5",
2
+ "spec": "1.18.0",
3
3
  "locale": "hu",
4
4
  "cases": [
5
5
  {
@@ -1,5 +1,5 @@
1
1
  {
2
- "spec": "1.17.5",
2
+ "spec": "1.18.0",
3
3
  "locale": "it",
4
4
  "cases": [
5
5
  {
@@ -1,5 +1,5 @@
1
1
  {
2
- "spec": "1.17.5",
2
+ "spec": "1.18.0",
3
3
  "cases": [
4
4
  {
5
5
  "id": "exact-en-us",
@@ -1,5 +1,5 @@
1
1
  {
2
- "spec": "1.17.5",
2
+ "spec": "1.18.0",
3
3
  "locale": "lt",
4
4
  "cases": [
5
5
  {
@@ -1,5 +1,5 @@
1
1
  {
2
- "spec": "1.17.5",
2
+ "spec": "1.18.0",
3
3
  "locale": "nl",
4
4
  "cases": [
5
5
  {
@@ -1,5 +1,5 @@
1
1
  {
2
- "spec": "1.17.5",
2
+ "spec": "1.18.0",
3
3
  "locale": "pl",
4
4
  "cases": [
5
5
  {
@@ -1,5 +1,5 @@
1
1
  {
2
- "spec": "1.17.5",
2
+ "spec": "1.18.0",
3
3
  "locale": "pt-BR",
4
4
  "cases": [
5
5
  {
@@ -1,5 +1,5 @@
1
1
  {
2
- "spec": "1.17.5",
2
+ "spec": "1.18.0",
3
3
  "locale": "pt-PT",
4
4
  "cases": [
5
5
  {
@@ -1,5 +1,5 @@
1
1
  {
2
- "spec": "1.17.5",
2
+ "spec": "1.18.0",
3
3
  "locale": "ro",
4
4
  "cases": [
5
5
  {
@@ -1,5 +1,5 @@
1
1
  {
2
- "spec": "1.17.5",
2
+ "spec": "1.18.0",
3
3
  "locale": "ru",
4
4
  "cases": [
5
5
  {
@@ -1,5 +1,5 @@
1
1
  {
2
- "spec": "1.17.5",
2
+ "spec": "1.18.0",
3
3
  "locale": "sk",
4
4
  "cases": [
5
5
  {
@@ -1,5 +1,5 @@
1
1
  {
2
- "spec": "1.17.5",
2
+ "spec": "1.18.0",
3
3
  "locale": "sl",
4
4
  "cases": [
5
5
  {
@@ -1,5 +1,5 @@
1
1
  {
2
- "spec": "1.17.5",
2
+ "spec": "1.18.0",
3
3
  "locale": "sr",
4
4
  "cases": [
5
5
  {
@@ -1,5 +1,5 @@
1
1
  {
2
- "spec": "1.17.5",
2
+ "spec": "1.18.0",
3
3
  "locale": "sv",
4
4
  "cases": [
5
5
  {
@@ -1,5 +1,5 @@
1
1
  {
2
- "spec": "1.17.5",
2
+ "spec": "1.18.0",
3
3
  "locale": "tr",
4
4
  "cases": [
5
5
  {
@@ -1,5 +1,5 @@
1
1
  {
2
- "spec": "1.17.5",
2
+ "spec": "1.18.0",
3
3
  "locale": "uk",
4
4
  "cases": [
5
5
  {
@@ -52,7 +52,7 @@
52
52
  "rule": "dashes",
53
53
  "cite": "Διοργανικό εγχειρίδιο σύνταξης κειμένων (EL, 2011), §10.1.8 «Ενωτικό — Παύλα μεσαίου μεγέθους — Μεγάλη παύλα»: για τα αριθμητικά διαστήματα, «Παύλα μεσαίου μεγέθους ή μείον (–) […] γ) για να δηλώσει το διάστημα μεταξύ δύο ορίων (π.χ. άτομα ηλικίας 25–45 ετών) […] δ) στην αναφορά σε περιόδους τουλάχιστον δύο πλήρων ετών (π.χ. 1989–1991) […] Ωστόσο, και στην περίπτωση αυτή μπορεί να χρησιμοποιηθεί και ενωτικό». Ο ίδιος κανόνας για τα αριθμητικά διαστήματα εμφανίζεται ΔΥΟ ΦΟΡΕΣ στην §10.1.8, μία στην υποενότητα «Ενωτικό (-)» και μία στην υποενότητα «Παύλα μεσαίου μεγέθους (–)», και κάθε φορά η υποενότητα παραχωρεί ρητά το άλλο σημείο (paraphrase of the «Ενωτικό» occurrence — its verbatim text was not captured at review time; the «Παύλα» occurrence is quoted above verbatim)· για την παρενθετική χρήση, «Όπως η παρένθεση, η διπλή παύλα δεν χωρίζεται με κενά διαστήματα από τη λέξη, φράση ή πρόταση που περικλείει· αντίθετα, μπαίνουν διαστήματα πριν από την πρώτη και μετά τη δεύτερη παύλα»",
54
54
  "url": "http://publications.europa.eu/resource/cellar/e774ea2a-ef84-4bf6-be92-c9ebebf91c1b.0015.03/DOC_2",
55
- "note": "Justifies \"none\" on BOTH fields, which no other locale has — but for TWO DIFFERENT REASONS, and conflating them would misrepresent the source. Range: genuinely ambivalent. The rule appears twice in §10.1.8, once under «Ενωτικό (-)» and once under «Παύλα μεσαίου μεγέθους (–)», and each occurrence explicitly concedes the other mark. That mutual concession is the proof — a source that names both forms in both places is not being vague, it is declining to rank them, and substituting either would express a preference the citation does not carry. Parenthetical: NOT ambivalent. The guide prescribes a U+2014 pair (its own header gives «Alt 0151») with ordinary spaces outside the pair and none on the inner edges. \"none\" here is forced by a SCHEMA LIMIT, not by the source: locale.schema.json's dash enum has no value for asymmetric spacing — \"em-spaced\" puts a space on each side of each dash, which is precisely what this sentence forbids. See spec/rules/dashes.md §6 «el», which states the two justifications separately. SOURCE CONFLICT, recorded and deliberately not settled: this guide prescribes U+2014 for the parenthetical dash, while ΥΠΕΠΘ/ΙΤΥΕ Γραμματική Α΄–Γ΄ Γυμνασίου §3.3 appears to set U+2013 in the same role. The sources array has no way to represent a conflict — it models agreement, not disagreement — so it is recorded here in prose. Resolving it is with the operator and needs a source that ranks the two, not a third that adds a form. Retrieved 2026-08-15."
55
+ "note": "Justifies \"none\" on BOTH fields, which no other locale has — but for TWO DIFFERENT REASONS, and conflating them would misrepresent the source. Range: genuinely ambivalent. The rule appears twice in §10.1.8, once under «Ενωτικό (-)» and once under «Παύλα μεσαίου μεγέθους (–)», and each occurrence explicitly concedes the other mark. That mutual concession is the proof — a source that names both forms in both places is not being vague, it is declining to rank them, and substituting either would express a preference the citation does not carry. Parenthetical: NOT ambivalent. The guide prescribes a U+2014 pair (its own header gives «Alt 0151») with ordinary spaces outside the pair and none on the inner edges. \"none\" here is forced by the SOURCE CONFLICT below, not by a schema limit: the schema has expressed the spacing this sentence prescribes since \"em-paired\" (spec 1.18.0), but that value is an em dash, and the choice between em and en for Greek is not settled. See spec/rules/dashes.md §6 «el», which states the two justifications separately. SOURCE CONFLICT, recorded and deliberately not settled: this guide prescribes U+2014 for the parenthetical dash, while ΥΠΕΠΘ/ΙΤΥΕ Γραμματική Α΄–Γ΄ Γυμνασίου §3.3 appears to set U+2013 in the same role. The sources array has no way to represent a conflict — it models agreement, not disagreement — so it is recorded here in prose. Resolving it is with the operator and needs a source that ranks the two, not a third that adds a form. Retrieved 2026-08-15."
56
56
  },
57
57
  {
58
58
  "rule": "ellipsis",
@@ -19,7 +19,7 @@
19
19
  }
20
20
  },
21
21
  "dash": {
22
- "parenthetical": "em-spaced",
22
+ "parenthetical": "em-paired",
23
23
  "range": "none"
24
24
  },
25
25
  "ellipsis": {
@@ -67,7 +67,7 @@
67
67
  "rule": "dashes",
68
68
  "cite": "RAE y ASALE, Diccionario panhispánico de dudas, entrada «raya», apartado 2: «Como el resto de los signos dobles, se escriben pegadas a la primera y a la última palabra del periodo que enmarcan, y separadas por un espacio de la palabra o signo que las precede o las sigue»",
69
69
  "url": "https://www.rae.es/dpd/raya",
70
- "note": "parenthetical = \"em-spaced\" ES UNA APROXIMACIÓN CONSCIENTE, no la norma, y se fijó por decisión del operador (27.10.2026). La fuente es explícita y prescribe espacios ORDINARIOS FUERA del par y ninguno en los bordes interiores («Llegó —por fin— a casa»). El enum no expresa esa asimetría: \"em-spaced\" pone un espacio a cada lado de CADA raya, de modo que el texto resultante lleva dos espacios interiores que el DPD no admite («Llegó — por fin — a casa»); \"em-tight\" suprimiría los exteriores que la frase exige y pegaría la raya a la palabra anterior, que es peor. Se eligió \"em-spaced\" porque conserva la longitud correcta del signo (U+2014, no el guion U+002D que el autor escribió) y los espacios exteriores, y solo falla en los interiores. Mientras el esquema no pueda expresar la asimetría, este valor sigue siendo una aproximación y no indica que el DPD prescriba espacios interiores. Hasta spec 1.16.0 el valor era \"none\" (la regla no tocaba nada). Consultado el 18.09.2026."
70
+ "note": "parenthetical = \"em-paired\": la raya en U+2014 escrita como par, con un espacio ORDINARIO fuera del par y ninguno en los bordes interiores («Llegó —por fin— a casa»), que es lo que prescribe la fuente. La regla solo convierte una frase que contiene exactamente dos rayas candidatas; un guion suelto («Madrid - la capital») o una frase con más de dos rayas se deja como está, porque el DPD no describe el uso de la raya en esas posiciones y nada permite decidir cuál abre y cuál cierra. Las rayas de diálogo («—Hola —dijo—.») no se tocan. Hasta spec 1.17.5 el valor era \"em-spaced\", una aproximación que dejaba dos espacios interiores que el DPD no admite; hasta spec 1.16.0 era \"none\". Consultado el 18.09.2026."
71
71
  },
72
72
  {
73
73
  "rule": "ranges",
@@ -1,5 +1,5 @@
1
1
  {
2
- "spec": "1.17.5",
2
+ "spec": "1.18.0",
3
3
  "$comment": "Locale resolution input. Algorithm is specified in spec/rules/locale-resolution.md and is identical in every runtime — never delegate it to a platform locale-negotiation library. \"spec\" here must track spec/VERSION exactly — it is not itself the global version source; scripts/validate-spec.mjs enforces the match.",
4
4
  "locales": [
5
5
  "en-US",
@@ -4,7 +4,7 @@
4
4
  **Spec version:** 0.6.0 (0.2.0 for everything except the 0.5.0/0.6.0 changes noted inline and in
5
5
  §8 History), amended in **1.3.0**: §1 and §3.4's statement of what belongs to `ranges`, §3.2a's
6
6
  re-entry condition, and §3.2 steps 7 and 8, all for [ranges.md](ranges.md) §3.2a's closed-up
7
- symbols.
7
+ symbols, and again in **1.18.0**: the value `"em-paired"` and its own algorithm, §3.4a.
8
8
 
9
9
  ---
10
10
 
@@ -39,14 +39,17 @@ in every prior spec version, since the two branches were always mutually exclusi
39
39
  0.5.0 split makes that exclusivity a boundary between two rules instead of two branches of one.
40
40
 
41
41
  The parenthetical form is governed by one enum in the locale file: a dash _length_ (`em` or `en`)
42
- and a _spacing_ (`tight` or `spaced`), or the opt-out value `none`.
42
+ and a _spacing_ (`tight` or `spaced`), the opt-out value `none`, or — since spec 1.18.0 — the one
43
+ value that is neither symmetric nor decided token by token, `em-paired` (§3.4a).
43
44
 
44
45
  ---
45
46
 
46
47
  ## 2. Locale data consumed
47
48
 
48
- - `dash.parenthetical` — one of `"em-tight"`, `"em-spaced"`, `"en-tight"`, `"en-spaced"`,
49
- `"none"`.
49
+ - `dash.parenthetical` — one of `"em-tight"`, `"em-spaced"`, `"em-paired"`, `"en-tight"`,
50
+ `"en-spaced"`, `"none"`. `"em-paired"` (spec 1.18.0) writes the pair as an em dash with a space
51
+ **outside** the pair and none inside it (`a —b— c`); it is specified in §3.4a, because it is the
52
+ one value whose result depends on which stroke opens the pair and which closes it.
50
53
 
51
54
  **As of spec 0.5.0, `dashes` no longer reads `dash.range`** — that field is now read exclusively
52
55
  by `ranges` ([ranges.md](ranges.md) §2), under the same key, with the same values and the same
@@ -164,7 +167,9 @@ U+2011 they meant it.
164
167
  then "counts on the left only") each fixed the witness in front of them and left the next
165
168
  one reachable; §8.2 defect (d) records how.
166
169
 
167
- 4. **Symmetry guard.** If `lsp ≠ rsp`, emit nothing and continue. A stroke that is spaced on
170
+ 4. **Symmetry guard.** If `lsp ≠ rsp`, emit nothing and continue. (`"em-paired"` is the one
171
+ exception: §3.4a counts such a stroke and, when it is a single U+2014, reads it as half of a
172
+ pair.) A stroke that is spaced on
168
173
  one side and tight on the other is not a dash: it is `--force`, `-5 °C`, a Markdown bullet
169
174
  `- item`, a signature `-- Iurii`, or an arrow `->`. This single guard removes the large
170
175
  majority of false positives and it applies to both the range and the parenthetical
@@ -500,6 +505,7 @@ Emit one edit replacing `cp[s - lsp … e - 1 + rsp]` with:
500
505
  | -------------------- | ------------------------------------------------------- |
501
506
  | `"em-tight"` | U+2014 |
502
507
  | `"em-spaced"` | U+0020 U+2014 U+0020 |
508
+ | `"em-paired"` | _(not a per-token replacement — see §3.4a)_ |
503
509
  | `"en-tight"` | U+2013 |
504
510
  | `"en-spaced"` | U+0020 U+2013 U+0020 |
505
511
  | `"none"` | _(emit nothing — the token is left exactly as written)_ |
@@ -507,6 +513,55 @@ Emit one edit replacing `cp[s - lsp … e - 1 + rsp]` with:
507
513
  **Subject to guards T1 and T2 (§3.2 steps 8 and 9), evaluated now that the form is known.** If the replacement is identical to the span it replaces, emit
508
514
  nothing.
509
515
 
516
+ ### 3.4a `"em-paired"` — the pair as a unit (spec 1.18.0)
517
+
518
+ When `dash.parenthetical` is `"em-paired"`, §3.4 is replaced by this section and the token loop
519
+ of §3.2 is replaced by a loop over **segments**. Everything else a stroke must satisfy — steps 2,
520
+ 5, 6 and 7 of §3.2, §3.2a, P1, P4 and P5 — still applies, because those guards decide whether a
521
+ stroke is a dash at all.
522
+
523
+ **Segment.** A segment is a stretch of the text unit bounded by the unit's start and end and by
524
+ a *terminal*: U+002E, U+003F, U+0021 or U+2026 that is followed by U+0020, a `NOBREAK-SPACE`, a
525
+ `BREAK`, a marker, or the end of the unit. A `NOBREAK-SPACE` counts because `nbsp` (order 70) puts
526
+ one after a terminal (`Sr. Pérez`, `p. ej.`); if only U+0020 closed a segment, that edit would
527
+ move the boundary and a second pass would pair strokes the first had kept apart. A terminal followed by anything else (`3.5`, `—¿quién?—`) does not end a
528
+ segment. A `BREAK` is **not** a segment boundary.
529
+
530
+ **Counted strokes.** Every maximal `DASH` run of §3.2 step 1 in a segment is classified:
531
+
532
+ - *Not a stroke*, and not counted: a run with `k > 3`; a range candidate (§3.3, ranges.md §3.2),
533
+ decided by its flanks alone, **whether or not the run is symmetric** (`5 -10` is a candidate);
534
+ a single hyphen-shaped run (U+002D, U+2010, U+2212) with `lsp = rsp = 0`, which is a compound;
535
+ and the U+002D of the literal sequence `+/-`, which `symbols` (order 80) turns into `±`.
536
+ - *Counted and eligible:* every other run that passes steps 5, 6 and 7, §3.2a, P4 and P5 and is
537
+ either symmetric (`lsp = rsp`) or a **single U+2014** spaced on one side only.
538
+ - *Counted and ineligible:* every other run — including a stroke at the start or end of a line
539
+ (the dialogue dash), one next to a `NOBREAK-SPACE`, `-mente`, `pre-`, `--force`, a Markdown
540
+ bullet and a lone U+2013.
541
+
542
+ **Decision.** A segment is converted if and only if **all** of the following hold; otherwise the
543
+ whole segment is left exactly as typed:
544
+
545
+ 1. it has **exactly two** counted strokes, and both are eligible;
546
+ 2. no `BREAK` lies between them;
547
+ 3. the first is not shaped like a closer (`lsp = 0`, `rsp = 1`) and the second is not shaped like
548
+ an opener (`lsp = 1`, `rsp = 0`);
549
+ 4. T1 (§3.2 step 8) does not block a side on which a space is inserted (step 5 below).
550
+
551
+ **Emission.** For the first stroke (the opener) and the second (the closer), the glyph is U+2014
552
+ and the **inner** side — to the right of the opener, to the left of the closer — is made tight,
553
+ removing a U+0020 if there was one. The **outer** side keeps a U+0020 if it had one; if it had
554
+ none, a U+0020 is inserted when the neighbouring code point `cp[L]` (opener) or `cp[R]` (closer)
555
+ is in `LETTER` or `DIGIT`, and otherwise the side stays tight, so `(—`, `«—`, `—»` and
556
+ `—por fin—.` are produced without a space the `spaces` rule would remove. T2 (§3.2 step 9) is
557
+ therefore vacuous: a space is only ever inserted beside a letter or a digit. A replacement
558
+ identical to the span it replaces is not emitted.
559
+
560
+ **What is left alone, by construction.** A lone stroke (`Madrid - la capital`); three or more
561
+ strokes in a segment; a pair split by a terminal (`p. ej.`) or by a line break; the dialogue dash
562
+ and any segment holding it (`—Hola —dijo—.`); affix hyphens; a pair whose strokes are separated by
563
+ a `NOBREAK-SPACE`. These are the accepted cost of not guessing which stroke opens which (§7.8).
564
+
510
565
  ### 3.5 Continue, and the non-overlap guarantee
511
566
 
512
567
  Set `i` to the index just past the token's run in the **input** array and continue from §3.2
@@ -689,6 +744,33 @@ Every input to a declined token's verdict is therefore unaffected by any edit `T
689
744
  pass: `T(T(x))` declines exactly where `T(x)` declined. The historical counterexamples that led to
690
745
  this guard set — before it closed every gap — are summarised in §8.2.
691
746
 
747
+ ### 5.2a `"em-paired"` — the segment verdict is stable (spec 1.18.0)
748
+
749
+ Under `"em-paired"` the verdict is made per segment (§3.4a), not per token, so §5.1's table does
750
+ not apply and the argument is rebuilt on the three facts a segment verdict reads.
751
+
752
+ - **Fixed points.** The output form has two U+2014 strokes, each spaced on its outer side only (or
753
+ tight beside a non-letter), which is eligible and correctly shaped, and the replacement of each
754
+ is byte-identical to its span: nothing is emitted.
755
+ - **The segment boundaries do not move.** Edits touch only a dash run and the U+0020 beside it.
756
+ A U+0020 is inserted only after a letter, a digit or a dash, never after a terminal, and the
757
+ first stroke after a terminal is an opener whose outer U+0020 is kept, so a terminal followed by
758
+ a space stays followed by one. No edit creates or removes a terminal.
759
+ - **The counted set does not change in the dangerous direction.** A converted segment can become
760
+ declined on the next pass (`a - 5 - b` gives `a —5— b`, whose two strokes then share a
761
+ cluster with the digit, §3.2 step 7), and that is harmless: a declined segment emits nothing, so
762
+ the text is already a fixed point. What would break idempotency is "declined → converted" on a
763
+ later pass, which needs a counted stroke to disappear, or the segment to change. A stroke that
764
+ was declined stays declined for the reasons of §5.2.
765
+ - **Composition obligation on later rules.** Every rule after `dashes` must leave the count of
766
+ counted strokes in a segment alone. `hyphen` (order 35) emits only `INERT-DASH`, which is not a
767
+ `DASH`; `symbols` rewrites `+/-` into `±`, which is why that hyphen is *not a stroke* above;
768
+ `nbsp` changes only U+0020 into `NOBREAK-SPACE`, which can make a stroke ineligible but moves
769
+ no segment boundary, because a terminal closes a segment before either.
770
+
771
+ The one direction that would break idempotency is "declined → converted" on the second pass. It
772
+ needs a counted stroke to disappear or a segment boundary to move; neither can happen.
773
+
692
774
  ### 5.3 Interaction with `ranges` is stable
693
775
 
694
776
  `ranges` (order 25) runs before `dashes` (order 30) in every pass. Three guarantees hold between
@@ -903,8 +985,8 @@ never reaches a digit-flanked token at all.
903
985
  The first locale with `"none"` on **both** fields, so the rule is a **total no-op** for it in the
904
986
  same provable sense `hyphen` is a no-op for a locale with empty lists. It is no longer the only
905
987
  one: `tr` (spec 1.6.0) is both-`none` too, and `es` was from spec 1.3.0 until 1.17.1, each for its
906
- own reason and each recorded in its own locale file. (`es` now sets `parenthetical` to `"em-spaced"`
907
- as a known approximation — see its locale file — and keeps `range` at `"none"`.) This section stays written about `el` because its two fields are
988
+ own reason and each recorded in its own locale file. (`es` sets `parenthetical` to `"em-paired"` since spec 1.18.0 — it was
989
+ `"em-spaced"`, an approximation, from 1.17.0 — and keeps `range` at `"none"`.) This section stays written about `el` because its two fields are
908
990
  `"none"` for two *different* reasons, which is what makes it worth reading. Tokens are still
909
991
  classified — §3.3's "a range token is never reconsidered as a parenthetical" still holds — but no
910
992
  classification has an emission to make. The rows below are therefore all "no change" rows by
@@ -922,18 +1004,18 @@ the guide is explicit.
922
1004
  that names both forms in both places is not being vague; it is declining to rank them.
923
1005
  Emitting either would express a preference the citation does not carry, so the rule emits
924
1006
  nothing.
925
- - **`parenthetical: "none"` — a schema limit, not ambivalence.** The guide prescribes a U+2014
1007
+ - **`parenthetical: "none"` — a source conflict, no longer a schema limit.** The guide prescribes a U+2014
926
1008
  pair (its own header gives «Alt 0151») and states the spacing exactly: «Όπως η παρένθεση, η
927
1009
  διπλή παύλα δεν χωρίζεται με κενά διαστήματα από τη λέξη, φράση ή πρόταση που περικλείει·
928
1010
  αντίθετα, μπαίνουν διαστήματα πριν από την πρώτη και μετά τη δεύτερη παύλα.» — ordinary spaces
929
- **outside** the pair, none on the **inner** edges. `dash.parenthetical`'s enum has no value for
930
- that asymmetry: `"em-spaced"` puts a space on each side of _each_ dash, which is precisely what
931
- the sentence forbids, and `"em-tight"` drops the outer spaces the sentence requires. So the
932
- source is clear and the schema cannot record it. `"none"` is what the file must say until the
933
- enum grows; the gap is with the operator and is **not** a licence to approximate.
934
-
935
- In both cases `"none"` is the honest encoding, not a placeholder awaiting a default (§2) — but
936
- only one of them is waiting on a _source_. `spec/locales/el.json`'s `dashes.note` carries the
1011
+ **outside** the pair, none on the **inner** edges. Until spec 1.18.0 the enum had no value for
1012
+ that asymmetry; `"em-paired"` (§3.4a) is that value. `el` stays `"none"` all the same, because
1013
+ `"em-paired"` is an em dash and the state school grammar appears to set an en dash in the same
1014
+ role: choosing between them needs a source that ranks the two, and is not a licence to
1015
+ approximate.
1016
+
1017
+ In both cases `"none"` is the honest encoding, not a placeholder awaiting a default (§2) — and
1018
+ both are now waiting on a _source_. `spec/locales/el.json`'s `dashes.note` carries the
937
1019
  detail, including a **source conflict** this document does not try to settle: the EU guide sets
938
1020
  U+2014 parenthetically while the state school grammar appears to set U+2013.
939
1021
 
@@ -1038,6 +1120,16 @@ development, the compound-label widening's original acceptance) moved to §8.
1038
1120
  exists, a port could transcribe either set and stay green. (T2 is shared with `ranges`, §3.3
1039
1121
  there — the same gap applies to a `-spaced` range replacement before U+2026.)
1040
1122
 
1123
+ 8. **`"em-paired"` converts only a segment with exactly two strokes (spec 1.18.0).** The rule has
1124
+ no syntax to read, so which stroke opens and which closes can only be inferred from there being
1125
+ two. The price, measured on fixtures and not yet on a corpus, is that these stay as typed:
1126
+ a lone spaced hyphen (`Madrid - la capital`), a sentence with two incisos (four strokes), an
1127
+ inciso holding an abbreviation or a `?`/`!` followed by a space, an inciso split by a line break,
1128
+ and any sentence that also holds a dialogue dash. The converse risk is a list written with
1129
+ spaced hyphens: `Madrid - Barcelona - Sevilla` becomes `Madrid —Barcelona— Sevilla`. The DPD
1130
+ names neither case, so both are recorded here and pinned by fixtures in `fixtures/es.json`
1131
+ rather than decided by a heuristic.
1132
+
1041
1133
  ---
1042
1134
 
1043
1135
  ## 8. History
@@ -1,5 +1,5 @@
1
1
  {
2
- "spec": "1.17.5",
2
+ "spec": "1.18.0",
3
3
  "$comment": "Single source of truth for pipeline order. Rules run in ascending `order`. Disabling a rule removes it from the sequence and never reorders the rest. Rule ids are public API (see docs/ARCHITECTURE.md section 5). \"spec\" here must track spec/VERSION exactly — it is not itself the global version source; scripts/validate-spec.mjs enforces the match.",
4
4
  "rules": [
5
5
  {
@@ -32,7 +32,7 @@
32
32
  "default": "on",
33
33
  "modes": ["text", "html", "markdown", "yaml"],
34
34
  "localeData": ["dash"],
35
- "summary": "Parenthetical dash per locale convention (dash.parenthetical). Never reinterprets a range candidate as a parenthetical dash — that shape belongs to `ranges` alone, whether or not `ranges` is enabled (spec 0.5.0; range detection moved out of this rule). A candidate is digit-flanked, or flanked by a closed-up symbol matched on the opposite member (spec 1.3.0; ranges.md §3.2a)."
35
+ "summary": "Parenthetical dash per locale convention (dash.parenthetical). Never reinterprets a range candidate as a parenthetical dash — that shape belongs to `ranges` alone, whether or not `ranges` is enabled (spec 0.5.0; range detection moved out of this rule). A candidate is digit-flanked, or flanked by a closed-up symbol matched on the opposite member (spec 1.3.0; ranges.md §3.2a). The value `em-paired` (spec 1.18.0) is decided per sentence rather than per token: it converts a sentence holding exactly two eligible strokes (dashes.md §3.4a)."
36
36
  },
37
37
  {
38
38
  "id": "hyphen",
@@ -190,6 +190,13 @@ or G3.
190
190
  violates `I₃` when it changes the **spacing** around a U+002D/U+2013/U+2014, because spacing
191
191
  is what `dashes` reads to decide whether a stroke is a parenthetical dash at all.
192
192
 
193
+ Under `dash.parenthetical = "em-paired"` (spec 1.18.0) the verdict is no longer local to a token:
194
+ `dashes` decides per segment, from the count and eligibility of the strokes in it (`dashes.md`
195
+ §3.4a, §5.2a). A later rule violates `I₃` there only by changing the count of counted strokes
196
+ or moving a segment boundary. `nbsp` changes U+0020 into a `NOBREAK-SPACE`, which dashes.md §3.4a
197
+ treats as closing a segment exactly as U+0020 does; `symbols` consumes the hyphen of `+/-`, which
198
+ §3.4a already excludes; `hyphen` emits only an `INERT-DASH`.
199
+
193
200
  **Who can violate it.** Nobody, since spec 0.1.0 — and it is worth recording that this line
194
201
  previously read _"`nbsp`, which inserts and converts space-like code points"_, which was true
195
202
  and was the source of two defects. `dashes` now treats both U+00A0 and U+202F as making an
@@ -38,8 +38,8 @@
38
38
  "required": ["parenthetical", "range"],
39
39
  "properties": {
40
40
  "parenthetical": {
41
- "enum": ["em-tight", "em-spaced", "en-tight", "en-spaced", "none"],
42
- "description": "Dash used for a parenthetical break. `-tight` means no surrounding spaces, `-spaced` means a regular space on each side (the leading one may later be made non-breaking by the nbsp rule). `none` means the rule must not substitute anything: either the locale has no verified convention, or the cited sources attest more than one form and rank none."
41
+ "enum": ["em-tight", "em-spaced", "em-paired", "en-tight", "en-spaced", "none"],
42
+ "description": "Dash used for a parenthetical break. `-tight` means no surrounding spaces, `-spaced` means a regular space on each side (the leading one may later be made non-breaking by the nbsp rule). `em-paired` is the em dash written as a pair, with a space outside the pair and none inside it (`a —b— c`). `none` means the rule must not substitute anything: either the locale has no verified convention, or the cited sources attest more than one form and rank none."
43
43
  },
44
44
  "range": {
45
45
  "enum": ["em-tight", "em-spaced", "en-tight", "en-spaced", "none"],
@@ -131,7 +131,7 @@ module Polytypo
131
131
 
132
132
  # The target dash glyph for a style value.
133
133
  def dash_code_point(style)
134
- style == "em-tight" || style == "em-spaced" ? EM_DASH : EN_DASH
134
+ style == "em-tight" || style == "em-spaced" || style == "em-paired" ? EM_DASH : EN_DASH
135
135
  end
136
136
 
137
137
  # Whether cp[start...end] is exactly `next`, code point for code point.
@@ -285,36 +285,47 @@ module Polytypo
285
285
  # `dashes` token becoming spaced can insert a space next to a `ranges` token's digit run,
286
286
  # and vice versa.
287
287
  def spacing_transition_blocked?(cp, left, right)
288
+ spacing_transition_blocked_left?(cp, left) || spacing_transition_blocked_right?(cp, right)
289
+ end
290
+
291
+ # The left half of T1, for a form that inserts a space on one side only (`em-paired`).
292
+ def spacing_transition_blocked_left?(cp, left)
288
293
  n = cp.length
289
294
 
290
295
  # Spec 1.3.0, position p1: step over a CLOSED-SYMBOL between the token and the run.
291
296
  left -= 1 if left >= 0 && left < n && closed_up_symbol?(cp[left]) && left.positive? &&
292
297
  digit?(cp[left - 1])
298
+
299
+ return false unless left >= 0 && left < n && digit?(cp[left])
300
+
301
+ d = left
302
+ d -= 1 while d.positive? && digit?(cp[d - 1])
303
+ # Position p2: and over one at the far end of the run.
304
+ i1 = effective_index(cp, skip_closed_up_symbol(cp, d - 1, -1), -1)
305
+ one = i1.nil? ? Polytypo::Engine::NONE : cp[i1]
306
+ two = i1.nil? ? Polytypo::Engine::NONE : effective_neighbour(cp, i1 - 1, -1)
307
+ return true if dash_union?(one)
308
+
309
+ (one == SPACE || no_break_space?(one)) && dash_union?(two)
310
+ end
311
+
312
+ # The right half of T1; see spacing_transition_blocked_left?.
313
+ def spacing_transition_blocked_right?(cp, right)
314
+ n = cp.length
315
+
293
316
  right += 1 if right >= 0 && right < n && closed_up_symbol?(cp[right]) &&
294
317
  right + 1 < n && digit?(cp[right + 1])
295
318
 
296
- if left >= 0 && left < n && digit?(cp[left])
297
- d = left
298
- d -= 1 while d.positive? && digit?(cp[d - 1])
299
- # Position p2: and over one at the far end of the run.
300
- i1 = effective_index(cp, skip_closed_up_symbol(cp, d - 1, -1), -1)
301
- one = i1.nil? ? Polytypo::Engine::NONE : cp[i1]
302
- two = i1.nil? ? Polytypo::Engine::NONE : effective_neighbour(cp, i1 - 1, -1)
303
- return true if dash_union?(one)
304
- return true if (one == SPACE || no_break_space?(one)) && dash_union?(two)
305
- end
319
+ return false unless right >= 0 && right < n && digit?(cp[right])
306
320
 
307
- if right >= 0 && right < n && digit?(cp[right])
308
- d = right
309
- d += 1 while d + 1 < n && digit?(cp[d + 1])
310
- i1 = effective_index(cp, skip_closed_up_symbol(cp, d + 1, 1), 1)
311
- one = i1.nil? ? Polytypo::Engine::NONE : cp[i1]
312
- two = i1.nil? ? Polytypo::Engine::NONE : effective_neighbour(cp, i1 + 1, 1)
313
- return true if dash_union?(one)
314
- return true if (one == SPACE || no_break_space?(one)) && dash_union?(two)
315
- end
321
+ d = right
322
+ d += 1 while d + 1 < n && digit?(cp[d + 1])
323
+ i1 = effective_index(cp, skip_closed_up_symbol(cp, d + 1, 1), 1)
324
+ one = i1.nil? ? Polytypo::Engine::NONE : cp[i1]
325
+ two = i1.nil? ? Polytypo::Engine::NONE : effective_neighbour(cp, i1 + 1, 1)
326
+ return true if dash_union?(one)
316
327
 
317
- false
328
+ (one == SPACE || no_break_space?(one)) && dash_union?(two)
318
329
  end
319
330
 
320
331
  # dashes.md 3.2 steps 1-7 and 3.2a, exactly as they read before the `ranges` split -- the
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "dash_shared"
4
+ require_relative "dashes_paired"
4
5
  require_relative "../edits"
5
6
  require_relative "../registry"
6
7
  require_relative "../unicode_util"
@@ -66,8 +67,10 @@ module Polytypo
66
67
  end
67
68
 
68
69
  def scan(cp, locale_data, _ctx)
69
- edits = []
70
70
  style = locale_data["dash"]["parenthetical"]
71
+ return DashesPaired.scan(cp) if style == "em-paired"
72
+
73
+ edits = []
71
74
 
72
75
  DashShared.find_tokens(cp).each do |token|
73
76
  # A digit-flanked token is `ranges`' territory, never `dashes`' -- declined
@@ -0,0 +1,160 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "dash_shared"
4
+ require_relative "../edits"
5
+ require_relative "../sentinels"
6
+ require_relative "../unicode_util"
7
+
8
+ module Polytypo
9
+ module Engine
10
+ module Rules
11
+ # `dash.parenthetical = "em-paired"` -- spec/rules/dashes.md 3.4a. The pair is written with a
12
+ # space outside it and none inside (`Llego —por fin— a casa`), which no per-token rule can
13
+ # express: which stroke opens and which closes is a property of the sentence, not of the
14
+ # token. A sentence is converted only when it holds exactly two counted strokes and both are
15
+ # eligible; anything else is left as typed. Mirrors dashes-paired.ts in the JS reference
16
+ # implementation.
17
+ module DashesPaired
18
+ # One counted DASH run of a segment: its span [s, e), outer spacing, the flanking indices
19
+ # after the joiner walk, and whether it may take part in a conversion.
20
+ Run = Struct.new(:s, :e, :lsp, :rsp, :left, :right, :eligible, keyword_init: true)
21
+
22
+ TERMINALS = [0x2e, 0x3f, 0x21, 0x2026].freeze
23
+
24
+ module_function
25
+
26
+ def terminal?(cp)
27
+ TERMINALS.include?(cp)
28
+ end
29
+
30
+ # A terminal ends a segment only before a space (U+0020 or a no-break space, which `nbsp`
31
+ # puts there later), a line break, a marker or the end of input.
32
+ def ends_segment?(cp, i)
33
+ return false unless terminal?(cp[i])
34
+ return true if i + 1 >= cp.length
35
+
36
+ nxt = cp[i + 1]
37
+ nxt == DashShared::SPACE || DashShared.no_break_space?(nxt) || DashShared.break?(nxt) ||
38
+ Polytypo::Engine.marker?(nxt)
39
+ end
40
+
41
+ def wants_space?(cp)
42
+ Polytypo::Engine::UnicodeUtil.letter?(cp) || DashShared.digit?(cp)
43
+ end
44
+
45
+ # dashes.md 3.2 steps 5-7: content on the same line, no dash or space beside the stroke's
46
+ # outer spacing, and not part of a cluster holding another dash run.
47
+ def neighbours_clean?(cp, s, e, left, right)
48
+ left_cp = cp[left]
49
+ right_cp = cp[right]
50
+ return false if DashShared.break?(left_cp) || DashShared.break?(right_cp)
51
+ return false if DashShared.inert_dash?(left_cp) || DashShared.inert_dash?(right_cp)
52
+ return false if DashShared.dash?(left_cp) || DashShared.dash?(right_cp)
53
+ return false if left_cp == DashShared::SPACE || DashShared.no_break_space?(left_cp)
54
+ return false if right_cp == DashShared::SPACE || DashShared.no_break_space?(right_cp)
55
+
56
+ !DashShared.cluster_inert?(cp, s, e)
57
+ end
58
+
59
+ # Returns nil when the run is not a stroke at all, otherwise a Run (eligible or not).
60
+ def classify(cp, s, e)
61
+ n = cp.length
62
+ k = e - s
63
+ return nil if k > 3
64
+
65
+ lsp = s.positive? && cp[s - 1] == DashShared::SPACE ? 1 : 0
66
+ rsp = e < n && cp[e] == DashShared::SPACE ? 1 : 0
67
+ first = cp[s]
68
+ is_hyphen_shaped = first == DashShared::HYPHEN_MINUS || first == DashShared::HYPHEN ||
69
+ first == DashShared::MINUS_SIGN
70
+ # A hyphen inside a word is a compound, not a stroke.
71
+ return nil if k == 1 && is_hyphen_shaped && lsp.zero? && rsp.zero?
72
+ # The hyphen of `+/-`, which `symbols` (order 80) turns into a plus-minus sign: counting
73
+ # it would let that later rule change the number of strokes in the segment.
74
+ return nil if k == 1 && first == DashShared::HYPHEN_MINUS && s >= 2 && cp[s - 1] == 0x2f && cp[s - 2] == 0x2b
75
+
76
+ left = s - 1 - lsp
77
+ right = e + rsp
78
+ counted = lambda do |eligible|
79
+ Run.new(s: s, e: e, lsp: lsp, rsp: rsp, left: left, right: right, eligible: eligible)
80
+ end
81
+ return counted.call(false) if left.negative? || right >= n
82
+
83
+ join_start = left + 1
84
+ join_end = right
85
+ left -= 1 while left >= 0 && cp[left] == DashShared::WORD_JOINER
86
+ right += 1 while right < n && cp[right] == DashShared::WORD_JOINER
87
+ return counted.call(false) if left.negative? || right >= n
88
+
89
+ crossed_joiner = left + 1 != join_start || right != join_end
90
+
91
+ return nil unless DashShared.range_flanks(cp, left, right).nil?
92
+ return counted.call(false) if crossed_joiner
93
+
94
+ return counted.call(false) unless neighbours_clean?(cp, s, e, left, right)
95
+
96
+ # A stroke spaced on one side only is eligible solely as the one em dash that is already
97
+ # half of the target form; `-mente`, `pre-`, `--force` and `- item` are not dashes.
98
+ return counted.call(false) if lsp != rsp && !(k == 1 && first == DashShared::EM_DASH)
99
+ # P4: a tight stroke between two Roman numerals is a range already in its correct form.
100
+ return counted.call(false) if lsp.zero? && rsp.zero? && Dashes.roman_flanked?(cp, left, right)
101
+ # P5: an authored lone en dash is declined in every locale.
102
+ return counted.call(false) if k == 1 && first == DashShared::EN_DASH
103
+
104
+ counted.call(true)
105
+ end
106
+
107
+ def scan(cp)
108
+ n = cp.length
109
+ edits = []
110
+ segment = []
111
+ i = 0
112
+
113
+ while i < n
114
+ if DashShared.dash?(cp[i])
115
+ s = i
116
+ e = s
117
+ e += 1 while e < n && DashShared.dash?(cp[e])
118
+ i = e
119
+ run = classify(cp, s, e)
120
+ segment << run unless run.nil?
121
+ next
122
+ end
123
+
124
+ if ends_segment?(cp, i)
125
+ emit_pair(cp, segment[0], segment[1], edits) if segment.length == 2
126
+ segment = []
127
+ end
128
+ i += 1
129
+ end
130
+ emit_pair(cp, segment[0], segment[1], edits) if segment.length == 2
131
+
132
+ edits
133
+ end
134
+
135
+ def emit_pair(cp, a, b, edits)
136
+ return unless a.eligible && b.eligible
137
+ return if a.lsp.zero? && a.rsp == 1 # the first stroke is shaped like a closer
138
+ return if b.lsp == 1 && b.rsp.zero? # the second stroke is shaped like an opener
139
+ return if (a.e...b.s).any? { |j| DashShared.break?(cp[j]) }
140
+
141
+ insert_before = a.lsp.zero? && wants_space?(cp[a.left])
142
+ insert_after = b.rsp.zero? && wants_space?(cp[b.right])
143
+ return if insert_before && DashShared.spacing_transition_blocked_left?(cp, a.left)
144
+ return if insert_after && DashShared.spacing_transition_blocked_right?(cp, b.right)
145
+
146
+ opener = a.lsp == 1 || insert_before ? [DashShared::SPACE, DashShared::EM_DASH] : [DashShared::EM_DASH]
147
+ closer = b.rsp == 1 || insert_after ? [DashShared::EM_DASH, DashShared::SPACE] : [DashShared::EM_DASH]
148
+
149
+ [[a, opener], [b, closer]].each do |run, replacement|
150
+ span_start = run.s - run.lsp
151
+ span_end = run.e + run.rsp
152
+ next if DashShared.same_content?(cp, span_start, span_end, replacement)
153
+
154
+ edits << Polytypo::Engine::Edit.new(span_start, span_end, replacement, "dashes")
155
+ end
156
+ end
157
+ end
158
+ end
159
+ end
160
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Polytypo
4
- VERSION = "1.17.5"
4
+ VERSION = "1.18.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.17.5
4
+ version: 1.18.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Iurii Rogulia
@@ -125,6 +125,7 @@ files:
125
125
  - lib/polytypo/engine/rules/apostrophe.rb
126
126
  - lib/polytypo/engine/rules/dash_shared.rb
127
127
  - lib/polytypo/engine/rules/dashes.rb
128
+ - lib/polytypo/engine/rules/dashes_paired.rb
128
129
  - lib/polytypo/engine/rules/ellipsis.rb
129
130
  - lib/polytypo/engine/rules/hyphen.rb
130
131
  - lib/polytypo/engine/rules/nbsp.rb