gedcomkit 0.1.8__tar.gz → 0.1.10__tar.gz

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 (98) hide show
  1. {gedcomkit-0.1.8 → gedcomkit-0.1.10}/CHANGELOG.md +62 -0
  2. {gedcomkit-0.1.8 → gedcomkit-0.1.10}/Cargo.lock +1 -1
  3. {gedcomkit-0.1.8 → gedcomkit-0.1.10}/Cargo.toml +1 -1
  4. {gedcomkit-0.1.8 → gedcomkit-0.1.10}/PKG-INFO +1 -1
  5. {gedcomkit-0.1.8 → gedcomkit-0.1.10}/bindings/gedcomkit-py/Cargo.lock +2 -2
  6. {gedcomkit-0.1.8 → gedcomkit-0.1.10}/bindings/gedcomkit-py/Cargo.toml +1 -1
  7. {gedcomkit-0.1.8 → gedcomkit-0.1.10}/bindings/gedcomkit-py/src/lib.rs +20 -0
  8. {gedcomkit-0.1.8 → gedcomkit-0.1.10}/src/build.rs +426 -0
  9. {gedcomkit-0.1.8 → gedcomkit-0.1.10}/src/lib.rs +109 -5
  10. {gedcomkit-0.1.8 → gedcomkit-0.1.10}/src/validate.rs +319 -49
  11. {gedcomkit-0.1.8 → gedcomkit-0.1.10}/LICENSE +0 -0
  12. {gedcomkit-0.1.8 → gedcomkit-0.1.10}/README.md +0 -0
  13. {gedcomkit-0.1.8 → gedcomkit-0.1.10}/bindings/gedcomkit-py/LICENSE +0 -0
  14. {gedcomkit-0.1.8 → gedcomkit-0.1.10}/bindings/gedcomkit-py/README.md +0 -0
  15. {gedcomkit-0.1.8 → gedcomkit-0.1.10}/bindings/gedcomkit-py/gedcomkit.pyi +0 -0
  16. {gedcomkit-0.1.8 → gedcomkit-0.1.10}/fixtures/vendored/gedcom-spec/LICENSE +0 -0
  17. {gedcomkit-0.1.8 → gedcomkit-0.1.10}/fixtures/vendored/gedcom-spec/README.md +0 -0
  18. {gedcomkit-0.1.8 → gedcomkit-0.1.10}/fixtures/vendored/gedcom-spec/cardinalities.tsv +0 -0
  19. {gedcomkit-0.1.8 → gedcomkit-0.1.10}/fixtures/vendored/gedcom-spec/enumerations.tsv +0 -0
  20. {gedcomkit-0.1.8 → gedcomkit-0.1.10}/fixtures/vendored/gedcom-spec/enumerationsets.tsv +0 -0
  21. {gedcomkit-0.1.8 → gedcomkit-0.1.10}/fixtures/vendored/gedcom-spec/grammar.abnf +0 -0
  22. {gedcomkit-0.1.8 → gedcomkit-0.1.10}/fixtures/vendored/gedcom-spec/grammar.gedstruct +0 -0
  23. {gedcomkit-0.1.8 → gedcomkit-0.1.10}/fixtures/vendored/gedcom-spec/payloads.tsv +0 -0
  24. {gedcomkit-0.1.8 → gedcomkit-0.1.10}/fixtures/vendored/gedcom-spec/substructures.tsv +0 -0
  25. {gedcomkit-0.1.8 → gedcomkit-0.1.10}/fixtures/vendored/gedcom7code/5/age-invalid.ged +0 -0
  26. {gedcomkit-0.1.8 → gedcomkit-0.1.10}/fixtures/vendored/gedcom7code/5/age-valid.ged +0 -0
  27. {gedcomkit-0.1.8 → gedcomkit-0.1.10}/fixtures/vendored/gedcom7code/5/atsign.ged +0 -0
  28. {gedcomkit-0.1.8 → gedcomkit-0.1.10}/fixtures/vendored/gedcom7code/5/char_ascii_1.ged +0 -0
  29. {gedcomkit-0.1.8 → gedcomkit-0.1.10}/fixtures/vendored/gedcom7code/5/char_ascii_2.ged +0 -0
  30. {gedcomkit-0.1.8 → gedcomkit-0.1.10}/fixtures/vendored/gedcom7code/5/char_utf16be-1.ged +0 -0
  31. {gedcomkit-0.1.8 → gedcomkit-0.1.10}/fixtures/vendored/gedcom7code/5/char_utf16be-2.ged +0 -0
  32. {gedcomkit-0.1.8 → gedcomkit-0.1.10}/fixtures/vendored/gedcom7code/5/char_utf16le-1.ged +0 -0
  33. {gedcomkit-0.1.8 → gedcomkit-0.1.10}/fixtures/vendored/gedcom7code/5/char_utf16le-2.ged +0 -0
  34. {gedcomkit-0.1.8 → gedcomkit-0.1.10}/fixtures/vendored/gedcom7code/5/char_utf8-1.ged +0 -0
  35. {gedcomkit-0.1.8 → gedcomkit-0.1.10}/fixtures/vendored/gedcom7code/5/char_utf8-2.ged +0 -0
  36. {gedcomkit-0.1.8 → gedcomkit-0.1.10}/fixtures/vendored/gedcom7code/5/char_utf8-3.ged +0 -0
  37. {gedcomkit-0.1.8 → gedcomkit-0.1.10}/fixtures/vendored/gedcom7code/5/date-all.ged +0 -0
  38. {gedcomkit-0.1.8 → gedcomkit-0.1.10}/fixtures/vendored/gedcom7code/5/date-dual-invalid.ged +0 -0
  39. {gedcomkit-0.1.8 → gedcomkit-0.1.10}/fixtures/vendored/gedcom7code/5/date-dual-valid.ged +0 -0
  40. {gedcomkit-0.1.8 → gedcomkit-0.1.10}/fixtures/vendored/gedcom7code/5/enum-ext.ged +0 -0
  41. {gedcomkit-0.1.8 → gedcomkit-0.1.10}/fixtures/vendored/gedcom7code/5/filename-1.ged +0 -0
  42. {gedcomkit-0.1.8 → gedcomkit-0.1.10}/fixtures/vendored/gedcom7code/5/lang-all.ged +0 -0
  43. {gedcomkit-0.1.8 → gedcomkit-0.1.10}/fixtures/vendored/gedcom7code/5/notes-1.ged +0 -0
  44. {gedcomkit-0.1.8 → gedcomkit-0.1.10}/fixtures/vendored/gedcom7code/5/obje-1.ged +0 -0
  45. {gedcomkit-0.1.8 → gedcomkit-0.1.10}/fixtures/vendored/gedcom7code/5/obsolete-1.ged +0 -0
  46. {gedcomkit-0.1.8 → gedcomkit-0.1.10}/fixtures/vendored/gedcom7code/5/pedi-1.ged +0 -0
  47. {gedcomkit-0.1.8 → gedcomkit-0.1.10}/fixtures/vendored/gedcom7code/5/rela_1.ged +0 -0
  48. {gedcomkit-0.1.8 → gedcomkit-0.1.10}/fixtures/vendored/gedcom7code/5/sour-1.ged +0 -0
  49. {gedcomkit-0.1.8 → gedcomkit-0.1.10}/fixtures/vendored/gedcom7code/5/tiny-1.ged +0 -0
  50. {gedcomkit-0.1.8 → gedcomkit-0.1.10}/fixtures/vendored/gedcom7code/5/xref-case.ged +0 -0
  51. {gedcomkit-0.1.8 → gedcomkit-0.1.10}/fixtures/vendored/gedcom7code/7/age-invalid.ged +0 -0
  52. {gedcomkit-0.1.8 → gedcomkit-0.1.10}/fixtures/vendored/gedcom7code/7/age-valid.ged +0 -0
  53. {gedcomkit-0.1.8 → gedcomkit-0.1.10}/fixtures/vendored/gedcom7code/7/atsign.ged +0 -0
  54. {gedcomkit-0.1.8 → gedcomkit-0.1.10}/fixtures/vendored/gedcom7code/7/char_ascii_1.ged +0 -0
  55. {gedcomkit-0.1.8 → gedcomkit-0.1.10}/fixtures/vendored/gedcom7code/7/char_ascii_2.ged +0 -0
  56. {gedcomkit-0.1.8 → gedcomkit-0.1.10}/fixtures/vendored/gedcom7code/7/char_utf16be-1.ged +0 -0
  57. {gedcomkit-0.1.8 → gedcomkit-0.1.10}/fixtures/vendored/gedcom7code/7/char_utf16be-2.ged +0 -0
  58. {gedcomkit-0.1.8 → gedcomkit-0.1.10}/fixtures/vendored/gedcom7code/7/char_utf16le-1.ged +0 -0
  59. {gedcomkit-0.1.8 → gedcomkit-0.1.10}/fixtures/vendored/gedcom7code/7/char_utf16le-2.ged +0 -0
  60. {gedcomkit-0.1.8 → gedcomkit-0.1.10}/fixtures/vendored/gedcom7code/7/char_utf8-1.ged +0 -0
  61. {gedcomkit-0.1.8 → gedcomkit-0.1.10}/fixtures/vendored/gedcom7code/7/char_utf8-2.ged +0 -0
  62. {gedcomkit-0.1.8 → gedcomkit-0.1.10}/fixtures/vendored/gedcom7code/7/char_utf8-3.ged +0 -0
  63. {gedcomkit-0.1.8 → gedcomkit-0.1.10}/fixtures/vendored/gedcom7code/7/date-all.ged +0 -0
  64. {gedcomkit-0.1.8 → gedcomkit-0.1.10}/fixtures/vendored/gedcom7code/7/date-dual-invalid.ged +0 -0
  65. {gedcomkit-0.1.8 → gedcomkit-0.1.10}/fixtures/vendored/gedcom7code/7/enum-ext.ged +0 -0
  66. {gedcomkit-0.1.8 → gedcomkit-0.1.10}/fixtures/vendored/gedcom7code/7/filename-1.ged +0 -0
  67. {gedcomkit-0.1.8 → gedcomkit-0.1.10}/fixtures/vendored/gedcom7code/7/lang-all.ged +0 -0
  68. {gedcomkit-0.1.8 → gedcomkit-0.1.10}/fixtures/vendored/gedcom7code/7/notes-1.ged +0 -0
  69. {gedcomkit-0.1.8 → gedcomkit-0.1.10}/fixtures/vendored/gedcom7code/7/obje-1.ged +0 -0
  70. {gedcomkit-0.1.8 → gedcomkit-0.1.10}/fixtures/vendored/gedcom7code/7/obsolete-1.ged +0 -0
  71. {gedcomkit-0.1.8 → gedcomkit-0.1.10}/fixtures/vendored/gedcom7code/7/pedi-1.ged +0 -0
  72. {gedcomkit-0.1.8 → gedcomkit-0.1.10}/fixtures/vendored/gedcom7code/7/rela_1.ged +0 -0
  73. {gedcomkit-0.1.8 → gedcomkit-0.1.10}/fixtures/vendored/gedcom7code/7/sour-1.ged +0 -0
  74. {gedcomkit-0.1.8 → gedcomkit-0.1.10}/fixtures/vendored/gedcom7code/7/tiny-1.ged +0 -0
  75. {gedcomkit-0.1.8 → gedcomkit-0.1.10}/fixtures/vendored/gedcom7code/7/xref-case.ged +0 -0
  76. {gedcomkit-0.1.8 → gedcomkit-0.1.10}/fixtures/vendored/gedcom7code/LICENSE +0 -0
  77. {gedcomkit-0.1.8 → gedcomkit-0.1.10}/fixtures/vendored/gedcom7code/UPSTREAM-README.md +0 -0
  78. {gedcomkit-0.1.8 → gedcomkit-0.1.10}/fixtures/vendored/zip-interop/README.md +0 -0
  79. {gedcomkit-0.1.8 → gedcomkit-0.1.10}/fixtures/vendored/zip-interop/python-zip64.gdz +0 -0
  80. {gedcomkit-0.1.8 → gedcomkit-0.1.10}/fixtures/vendored/zip-interop/python-zipfile.gdz +0 -0
  81. {gedcomkit-0.1.8 → gedcomkit-0.1.10}/pyproject.toml +0 -0
  82. {gedcomkit-0.1.8 → gedcomkit-0.1.10}/src/convert.rs +0 -0
  83. {gedcomkit-0.1.8 → gedcomkit-0.1.10}/src/dates.rs +0 -0
  84. {gedcomkit-0.1.8 → gedcomkit-0.1.10}/src/decode.rs +0 -0
  85. {gedcomkit-0.1.8 → gedcomkit-0.1.10}/src/family.rs +0 -0
  86. {gedcomkit-0.1.8 → gedcomkit-0.1.10}/src/gedzip.rs +0 -0
  87. {gedcomkit-0.1.8 → gedcomkit-0.1.10}/src/names.rs +0 -0
  88. {gedcomkit-0.1.8 → gedcomkit-0.1.10}/src/schema.rs +0 -0
  89. {gedcomkit-0.1.8 → gedcomkit-0.1.10}/src/synthetic.rs +0 -0
  90. {gedcomkit-0.1.8 → gedcomkit-0.1.10}/src/tags.rs +0 -0
  91. {gedcomkit-0.1.8 → gedcomkit-0.1.10}/src/view.rs +0 -0
  92. {gedcomkit-0.1.8 → gedcomkit-0.1.10}/tests/endurance.rs +0 -0
  93. {gedcomkit-0.1.8 → gedcomkit-0.1.10}/tests/external_charset.rs +0 -0
  94. {gedcomkit-0.1.8 → gedcomkit-0.1.10}/tests/properties.rs +0 -0
  95. {gedcomkit-0.1.8 → gedcomkit-0.1.10}/tests/requirement_corpus.rs +0 -0
  96. {gedcomkit-0.1.8 → gedcomkit-0.1.10}/tests/spec_conformance.rs +0 -0
  97. {gedcomkit-0.1.8 → gedcomkit-0.1.10}/tests/vendored_corpus.rs +0 -0
  98. {gedcomkit-0.1.8 → gedcomkit-0.1.10}/tests/version_conversion.rs +0 -0
@@ -5,6 +5,68 @@ format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and
5
5
  the project uses semantic versioning within the usual pre-1.0 compatibility
6
6
  rules.
7
7
 
8
+ ## [0.1.10] - 2026-09-27
9
+
10
+ ### Added
11
+
12
+ - `Node::fit_line_limit`, `Document::fit_line_limit` (the same, addressed by
13
+ a record's cross-reference identifier), and the `MAX_LINE_LENGTH` constant
14
+ for GEDCOM 5.5.1's 255-byte line, for a caller to fit an edited record to
15
+ a pre-7.0 document's line limit just before serializing:
16
+ `set_logical_value` only ever splits a payload across `CONT` at the
17
+ newlines it is given, so a long edited line was otherwise written over
18
+ length. Only nodes with no verbatim source line are touched, checked
19
+ recursively, so a node no caller reached into keeps its exact bytes; a
20
+ pointer payload is never split either. The line is measured with a
21
+ 2-byte terminator reserve — wider than `wrap_long_lines`' 1 byte — because
22
+ this is meant for a caller whose own pipeline may keep a source file's
23
+ `\r\n` terminator rather than always writing `\n`. Bound in both language
24
+ bindings as `fit_line_limit` (Python) and `fitLineLimit` (WASM), mirroring
25
+ `wrap_long_lines`/`wrapLongLines`.
26
+
27
+ ### Fixed
28
+
29
+ - `Document::next_xref` matched its prefix case-sensitively, so in a file
30
+ using lowercase identifiers (`@i1@`) `next_xref("I")` minted `@I1@` —
31
+ which `Document::record`'s case-insensitive lookup resolves to the
32
+ existing `@i1@`, silently colliding with it. The prefix match is now
33
+ case-insensitive, and the returned identifier is checked against every
34
+ existing xref case-insensitively and advanced past any collision. Its
35
+ doc comment claimed "the lowest unused" identifier while returning one
36
+ past the highest; the comment now says that.
37
+ - The same method minted `highest + 1` with unchecked arithmetic: a hostile
38
+ or fuzzed file carrying `@I18446744073709551615@` (`u64::MAX`) made it
39
+ panic in a debug build and wrap around to `@I0@` in release. Arithmetic
40
+ is now checked throughout, and once the series above the high-water mark
41
+ is exhausted, `next_xref` falls back to the lowest unused number instead
42
+ of overflowing.
43
+
44
+ ## [0.1.9] - 2026-09-27
45
+
46
+ ### Fixed
47
+
48
+ - `validate::inspect` drew years from dates the reader could not read: a
49
+ year merely mentioned in an unreadable payload (`WFT Est 1804-1911`) was
50
+ taken as the event's year, contrary to the module's own rule, and made
51
+ contradictions out of nothing. Such dates are now left out of every check.
52
+ - A date that bounds a year rather than naming one (`BEF 1811`, `AFT 1850`,
53
+ `BET 1800 AND 1810`) was compared as if it named its one mentioned year.
54
+ Checks now compare the years a date admits, so a finding is reported only
55
+ when no year both dates allow could be true, and the message says the
56
+ bounds it was drawn from (`born 1807 and died before 1805`; a parent age
57
+ of `at most 10 years old`).
58
+ - A birth dated after a death was reported twice, as `died-before-born` and
59
+ as `event-after-death`; it is now reported once.
60
+ - A Hebrew or French Republican year was compared as if it were Gregorian;
61
+ such dates are now left out of the checks. A range written backwards
62
+ (`BET 1910 AND 1900`) admits the same years as one written forwards.
63
+ - `child-born-after-mother-died` was skipped whenever the mother's own birth
64
+ year was unknown, although it needs only her death and the child's birth;
65
+ it now runs whenever those two are known, and alongside a
66
+ `parent-too-young` finding rather than instead of one. A child born before
67
+ its parent is worded as such (`5 years before the parent was born`), and a
68
+ lifespan between two exact years is stated exactly.
69
+
8
70
  ## [0.1.8] - 2026-09-27
9
71
 
10
72
  ### Added
@@ -35,7 +35,7 @@ dependencies = [
35
35
 
36
36
  [[package]]
37
37
  name = "gedcomkit"
38
- version = "0.1.8"
38
+ version = "0.1.10"
39
39
  dependencies = [
40
40
  "flate2",
41
41
  "serde",
@@ -3,7 +3,7 @@
3
3
  # was checked on crates.io, npm, and PyPI before the initial release.
4
4
  [package]
5
5
  name = "gedcomkit"
6
- version = "0.1.8"
6
+ version = "0.1.10"
7
7
  description = "A byte-preserving GEDCOM document model: decoding, parsing, readings, version conversion, plausibility checks, and the GEDZIP container, for GEDCOM 5.5 through 7.x."
8
8
  edition = "2024"
9
9
  # The true minimum, not the workspace's toolchain: a parser library is a leaf
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: gedcomkit
3
- Version: 0.1.8
3
+ Version: 0.1.10
4
4
  Classifier: Development Status :: 3 - Alpha
5
5
  Classifier: Programming Language :: Rust
6
6
  Classifier: Programming Language :: Python :: Implementation :: CPython
@@ -35,7 +35,7 @@ dependencies = [
35
35
 
36
36
  [[package]]
37
37
  name = "gedcomkit"
38
- version = "0.1.8"
38
+ version = "0.1.10"
39
39
  dependencies = [
40
40
  "flate2",
41
41
  "serde",
@@ -43,7 +43,7 @@ dependencies = [
43
43
 
44
44
  [[package]]
45
45
  name = "gedcomkit-py"
46
- version = "0.1.8"
46
+ version = "0.1.10"
47
47
  dependencies = [
48
48
  "gedcomkit",
49
49
  "pyo3",
@@ -2,7 +2,7 @@
2
2
  # abi3 keeps it to one wheel per platform across Python versions.
3
3
  [package]
4
4
  name = "gedcomkit-py"
5
- version = "0.1.8"
5
+ version = "0.1.10"
6
6
  description = "Python bindings for gedcomkit: an archival-grade GEDCOM library — byte-preserving round-trip, encoding detection, GEDCOM 5.5 through 7.x, GEDZIP."
7
7
  edition = "2024"
8
8
  rust-version = "1.88"
@@ -167,6 +167,26 @@ impl Gedcom {
167
167
  wrapped.iter().map(|line| to_py(py, line)).collect()
168
168
  }
169
169
 
170
+ /// Fits one record inside a 5.5.x document's line limit
171
+ /// (`max_line_bytes`, terminator included; 255 is GEDCOM 5.5.1's limit)
172
+ /// by inserting `CONC` lines, addressed by cross-reference identifier.
173
+ /// Call this on a record just edited with `set_logical_value`, since
174
+ /// that only ever splits a payload at the newlines it is given. Returns
175
+ /// whether anything changed; a node still carrying its original,
176
+ /// unedited line is never touched. Raises `KeyError` when no record
177
+ /// answers to `xref`.
178
+ #[pyo3(signature = (xref, max_line_bytes = 255))]
179
+ fn fit_line_limit(&mut self, xref: &str, max_line_bytes: usize) -> PyResult<bool> {
180
+ let changed = self
181
+ .document
182
+ .fit_line_limit(xref, max_line_bytes)
183
+ .map_err(|_| PyKeyError::new_err(xref.to_owned()))?;
184
+ if changed {
185
+ self.graph = None;
186
+ }
187
+ Ok(changed)
188
+ }
189
+
170
190
  /// What decoding decided, one line — show it before accepting an import.
171
191
  #[getter]
172
192
  fn encoding(&self) -> String {
@@ -186,6 +186,222 @@ pub struct WrappedLine {
186
186
  /// The fewest bytes a line limit may leave for level, tag, and payload.
187
187
  const SMALLEST_LINE_LIMIT: usize = 32;
188
188
 
189
+ /// GEDCOM 5.5.1's line length limit, in bytes.
190
+ ///
191
+ /// The specification: "The total length of a GEDCOM line, including level
192
+ /// number, cross-reference number, tag, value, delimiters, and terminator,
193
+ /// must not exceed 255 (wide) characters." GEDCOM 7.0 removed `CONC` and
194
+ /// every length limit, so this only matters for a document that declares
195
+ /// 5.5.x. Pass it to [`Node::fit_line_limit`] or
196
+ /// [`Document::wrap_long_lines`] — the two reserve a different number of
197
+ /// bytes for the line terminator; see [`Node::fit_line_limit`]'s own
198
+ /// documentation for why.
199
+ pub const MAX_LINE_LENGTH: usize = 255;
200
+
201
+ /// Bytes [`Node::fit_line_limit`] reserves for the line terminator when
202
+ /// measuring whether a line fits: exactly 2, for a `\r\n` (CRLF)
203
+ /// terminator. [`Document::wrap_long_lines`] reserves only 1, for the
204
+ /// `\n` this crate's own writer emits; `fit_line_limit` reserves the wider
205
+ /// CRLF instead because callers of this function may hand the result to a
206
+ /// pipeline that preserves the source file's own terminator — CRLF
207
+ /// included — rather than always writing LF. A line this leaves alone is
208
+ /// therefore safe under either terminator convention.
209
+ const TERMINATOR_RESERVE: usize = 2;
210
+
211
+ /// The bytes a line spends before its payload, payload delimiter included:
212
+ /// level, a space, the identifier and a space when there is one, the tag,
213
+ /// and the space that introduces the payload.
214
+ fn segment_prefix_bytes(level: usize, xref: Option<&str>, tag: &str) -> usize {
215
+ level.to_string().len() + 1 + xref.map_or(0, |xref| xref.len() + 1) + tag.len() + 1
216
+ }
217
+
218
+ /// Fits one payload segment — a node's own line plus the `CONC` lines that
219
+ /// continue it without a newline — inside `max_line` bytes, the way
220
+ /// [`Node::fit_line_limit`] documents. `old_concs` is that segment's
221
+ /// existing trailing `CONC` children (already removed from the tree by the
222
+ /// caller); the return value is what should replace them.
223
+ ///
224
+ /// Left alone, unconditionally: a segment whose head carries a verbatim
225
+ /// source line (its line is fixed, not ours to move text out of), a
226
+ /// segment with a verbatim `CONC` in it (same reason), a pointer payload
227
+ /// (pointers are short; splitting one would corrupt it), and a segment that
228
+ /// already fits. Otherwise every byte of the segment's folded text — the
229
+ /// head's value followed by each old `CONC`'s value, exactly as
230
+ /// [`Node::logical_value`] would fold them — is recut with [`cut`] and
231
+ /// redistributed, which is what guarantees the fold-after-split round trip.
232
+ fn resplit_segment(
233
+ level: usize,
234
+ xref: Option<&str>,
235
+ tag: &str,
236
+ verbatim: Option<&str>,
237
+ value: &mut Option<String>,
238
+ old_concs: Vec<Node>,
239
+ max_line: usize,
240
+ ) -> (Vec<Node>, bool) {
241
+ if verbatim.is_some() || old_concs.iter().any(|conc| conc.verbatim.is_some()) {
242
+ return (old_concs, false);
243
+ }
244
+ if value.as_deref().is_some_and(is_xref) {
245
+ return (old_concs, false);
246
+ }
247
+
248
+ let head_budget =
249
+ max_line.checked_sub(TERMINATOR_RESERVE + segment_prefix_bytes(level, xref, tag));
250
+ let conc_budget =
251
+ max_line.checked_sub(TERMINATOR_RESERVE + segment_prefix_bytes(level + 1, None, "CONC"));
252
+ // Four bytes is the widest UTF-8 character: less and a cut may not be
253
+ // able to make progress, so the line is left as it is.
254
+ let (Some(head_budget), Some(conc_budget)) = (head_budget, conc_budget) else {
255
+ return (old_concs, false);
256
+ };
257
+ if head_budget < 4 || conc_budget < 4 {
258
+ return (old_concs, false);
259
+ }
260
+
261
+ let head_fits = value.as_deref().map_or(0, str::len) <= head_budget;
262
+ let concs_fit = old_concs
263
+ .iter()
264
+ .all(|conc| conc.value.as_deref().map_or(0, str::len) <= conc_budget);
265
+ if head_fits && concs_fit {
266
+ return (old_concs, false);
267
+ }
268
+
269
+ let mut text = value.clone().unwrap_or_default();
270
+ for conc in &old_concs {
271
+ text.push_str(conc.value.as_deref().unwrap_or_default());
272
+ }
273
+
274
+ let mut pieces = cut(&text, head_budget, conc_budget).into_iter();
275
+ let head_piece = pieces.next().unwrap_or_default();
276
+ *value = (!head_piece.is_empty()).then(|| head_piece.to_owned());
277
+ let new_concs = pieces
278
+ .map(|piece| Node::with_value("CONC", piece))
279
+ .collect();
280
+ (new_concs, true)
281
+ }
282
+
283
+ impl Node {
284
+ /// Fits this node and its substructures inside a 5.5.x document's line
285
+ /// limit — [`MAX_LINE_LENGTH`] for the specification's own 255 bytes —
286
+ /// by inserting `CONC` lines. Returns whether anything changed.
287
+ ///
288
+ /// Call this on the records a caller edited in a pre-7.0 document, just
289
+ /// before serializing: [`Node::set_logical_value`] only ever splits a
290
+ /// payload across `CONT` at the newlines it is given, so an edited note
291
+ /// with one long paragraph is otherwise written as a single over-long
292
+ /// line. `level` is the nesting level this node is rendered at — the
293
+ /// same number [`Node::at`]'s caller already tracks, since a node does
294
+ /// not store its own level (see the type's own documentation).
295
+ ///
296
+ /// Only nodes with no verbatim source line are touched, checked at
297
+ /// every node this walks into, recursively: a node still carrying its
298
+ /// verbatim line — meaning a caller never reached into it — is left
299
+ /// exactly as it was, whether or not its rendered line is over length.
300
+ /// That is what keeps this additive next to the byte-preservation
301
+ /// guarantee: it only ever rewrites a node through the same setters an
302
+ /// editing caller already surrendered the node's verbatim line to use.
303
+ /// A payload read as a pointer ([`Node::pointer`]) is never split
304
+ /// either — pointers are well under any real line limit, and cutting
305
+ /// one would corrupt it.
306
+ ///
307
+ /// A too-long line becomes its payload's head plus `CONC` children
308
+ /// inserted right after whatever `CONC` lines already continued that
309
+ /// same line (so an existing, already-fitting split is preserved in
310
+ /// place rather than reordered); a too-long `CONT` line is split the
311
+ /// same way, into that `CONT` followed by its own new `CONC` siblings.
312
+ /// Every cut lands on a UTF-8 character boundary and, whenever the
313
+ /// payload has one within the limit, on a byte with a non-space
314
+ /// character on each side, since GEDCOM 5.5.1 warns that a line broken
315
+ /// beside a space loses that space on re-joining. Failing that — a run
316
+ /// of spaces wider than the limit, say — the cut falls back to the
317
+ /// latest legal byte boundary instead, which is documented behavior of
318
+ /// [`cut`] this call reuses, not a special case here.
319
+ ///
320
+ /// The rendered line is measured in UTF-8 bytes, including level,
321
+ /// optional cross-reference, tag, delimiters, and a terminator reserved
322
+ /// at 2 bytes — a `\r\n` (CRLF) terminator, not the single-byte `\n`
323
+ /// this crate's own writer emits. That is deliberately wider than
324
+ /// [`Document::wrap_long_lines`]'s 1-byte reserve: this function exists
325
+ /// for a caller that fits one edited record before handing the document
326
+ /// on to something that may write it with the source file's own
327
+ /// terminator, CRLF included, so a line this leaves alone is safe under
328
+ /// either convention.
329
+ ///
330
+ /// ```
331
+ /// use gedcomkit::{Node, build::MAX_LINE_LENGTH};
332
+ ///
333
+ /// let mut note = Node::record("@N1@", "NOTE");
334
+ /// note.set_logical_value(&"word ".repeat(80));
335
+ /// let changed = note.fit_line_limit(0, MAX_LINE_LENGTH);
336
+ /// assert!(changed);
337
+ /// assert_eq!(note.logical_value(), "word ".repeat(80));
338
+ /// ```
339
+ pub fn fit_line_limit(&mut self, level: usize, max_line: usize) -> bool {
340
+ fit_node(self, level, max_line)
341
+ }
342
+ }
343
+
344
+ fn fit_node(node: &mut Node, level: usize, max_line: usize) -> bool {
345
+ let run_len = node
346
+ .children
347
+ .iter()
348
+ .take_while(|child| matches!(child.tag.as_str(), "CONT" | "CONC"))
349
+ .count();
350
+ let mut run: Vec<Node> = node.children.drain(0..run_len).collect();
351
+ let self_conc_end = run
352
+ .iter()
353
+ .position(|child| child.tag == "CONT")
354
+ .unwrap_or(run.len());
355
+ let self_concs: Vec<Node> = run.drain(0..self_conc_end).collect();
356
+
357
+ let xref = node.xref.clone();
358
+ let tag = node.tag.clone();
359
+ let verbatim = node.verbatim.clone();
360
+ let (mut output, mut changed) = resplit_segment(
361
+ level,
362
+ xref.as_deref(),
363
+ &tag,
364
+ verbatim.as_deref(),
365
+ &mut node.value,
366
+ self_concs,
367
+ max_line,
368
+ );
369
+ if changed {
370
+ node.verbatim = None;
371
+ }
372
+
373
+ let mut segments = run.into_iter().peekable();
374
+ while let Some(mut cont) = segments.next() {
375
+ let mut concs = Vec::new();
376
+ while segments.peek().is_some_and(|next| next.tag == "CONC") {
377
+ concs.push(segments.next().expect("peeked"));
378
+ }
379
+ let cont_verbatim = cont.verbatim.clone();
380
+ let (new_concs, seg_changed) = resplit_segment(
381
+ level + 1,
382
+ None,
383
+ "CONT",
384
+ cont_verbatim.as_deref(),
385
+ &mut cont.value,
386
+ concs,
387
+ max_line,
388
+ );
389
+ if seg_changed {
390
+ cont.verbatim = None;
391
+ }
392
+ changed |= seg_changed;
393
+ output.push(cont);
394
+ output.extend(new_concs);
395
+ }
396
+
397
+ let inserted = output.len();
398
+ node.children.splice(0..0, output);
399
+ for child in node.children.iter_mut().skip(inserted) {
400
+ changed |= fit_node(child, level + 1, max_line);
401
+ }
402
+ changed
403
+ }
404
+
189
405
  impl Document {
190
406
  /// Adds substructures after the existing ones of the structure at `path`
191
407
  /// in `record` — a header's `DATE` and `SUBM`, say, which
@@ -301,6 +517,14 @@ impl Document {
301
517
  /// specification's warning, since some readers strip the space at the
302
518
  /// end of a line and lose it from the joined value.
303
519
  ///
520
+ /// The terminator is reserved at 1 byte here, matching the single `\n`
521
+ /// this crate's own writer emits ([`Document::to_text`],
522
+ /// [`Document::to_bytes`]) — this function measures the document it is
523
+ /// about to write out itself. [`Node::fit_line_limit`] reserves 2 bytes
524
+ /// instead, for a caller that fits one record ahead of writing the
525
+ /// document through something else that may keep the source file's own
526
+ /// terminator, CRLF included.
527
+ ///
304
528
  /// This rewrites what it touches, so it is an explicit operation with a
305
529
  /// report rather than something writing does: a structure that fits is
306
530
  /// left exactly as it is, and one that does not has its payload's
@@ -354,6 +578,37 @@ impl Document {
354
578
  }
355
579
  Ok(wrapped)
356
580
  }
581
+
582
+ /// [`Node::fit_line_limit`] for one record, addressed by cross-reference
583
+ /// identifier — the `Document`-level entry point for it, the way
584
+ /// [`Document::record_mut`] is for reaching a record to edit it in the
585
+ /// first place. Records sit at level 0, so unlike [`Node::fit_line_limit`]
586
+ /// there is no level for the caller to track. Returns whether anything
587
+ /// changed.
588
+ ///
589
+ /// ```
590
+ /// use gedcomkit::{Document, Node, build::MAX_LINE_LENGTH};
591
+ ///
592
+ /// let mut document = Document::new_v551("EXAMPLE");
593
+ /// let mut note = Node::record("@N1@", "NOTE");
594
+ /// note.set_logical_value(&"word ".repeat(80));
595
+ /// document.append_records(vec![note])?;
596
+ ///
597
+ /// assert!(document.fit_line_limit("@N1@", MAX_LINE_LENGTH)?);
598
+ /// assert!(document.to_text().lines().all(|line| line.len() < MAX_LINE_LENGTH));
599
+ /// # Ok::<(), gedcomkit::GedcomError>(())
600
+ /// ```
601
+ ///
602
+ /// # Errors
603
+ ///
604
+ /// A [`GedcomErrorKind::Structure`] error when no record answers to
605
+ /// `xref`.
606
+ pub fn fit_line_limit(&mut self, xref: &str, max_line: usize) -> Result<bool, GedcomError> {
607
+ let record = self
608
+ .record_mut(xref)
609
+ .ok_or_else(|| fault(0, GedcomErrorKind::Structure, format!("no record {xref}")))?;
610
+ Ok(record.fit_line_limit(0, max_line))
611
+ }
357
612
  }
358
613
 
359
614
  fn wrap_node(
@@ -708,4 +963,175 @@ mod tests {
708
963
  assert!(!piece.ends_with(' '), "{piece:?}");
709
964
  }
710
965
  }
966
+
967
+ fn fits(node: &Node, max_line: usize) -> bool {
968
+ Document {
969
+ records: vec![node.clone()],
970
+ }
971
+ .to_text()
972
+ .lines()
973
+ .all(|line| line.len() < max_line)
974
+ }
975
+
976
+ #[test]
977
+ fn fit_line_limit_splits_a_long_edited_ascii_payload() {
978
+ let mut note = Node::record("@N1@", "NOTE");
979
+ let text = "word ".repeat(80);
980
+ note.set_logical_value(&text);
981
+ assert!(note.verbatim().is_none(), "an edited node has no verbatim");
982
+
983
+ assert!(note.fit_line_limit(0, MAX_LINE_LENGTH));
984
+ assert_eq!(note.logical_value(), text, "fold(split(x)) == x");
985
+ assert!(fits(&note, MAX_LINE_LENGTH));
986
+ assert!(
987
+ note.all("CONC").count() > 0,
988
+ "the overflow became CONC children"
989
+ );
990
+ // A second pass has nothing left to do.
991
+ assert!(!note.fit_line_limit(0, MAX_LINE_LENGTH));
992
+ }
993
+
994
+ #[test]
995
+ fn fit_line_limit_splits_multibyte_text_on_character_boundaries() {
996
+ let mut note = Node::record("@N1@", "NOTE");
997
+ let text = "Pointe-Claire, Île de Montréal, Québec ".repeat(12);
998
+ note.set_logical_value(text.trim_end());
999
+
1000
+ assert!(note.fit_line_limit(0, 80));
1001
+ assert_eq!(note.logical_value(), text.trim_end());
1002
+ assert!(fits(&note, 80));
1003
+ assert!(note.all("CONC").count() > 0);
1004
+ }
1005
+
1006
+ #[test]
1007
+ fn fit_line_limit_handles_a_space_at_every_candidate_boundary() {
1008
+ let mut note = Node::record("@N1@", "NOTE");
1009
+ // Every legal cut point in `x x x ...` has a space on at least one
1010
+ // side of it, exercising the fallback in `best_cut`.
1011
+ let text = "x ".repeat(200);
1012
+ note.set_logical_value(text.trim_end());
1013
+
1014
+ assert!(note.fit_line_limit(0, 40));
1015
+ assert_eq!(note.logical_value(), text.trim_end());
1016
+ assert!(fits(&note, 40));
1017
+ }
1018
+
1019
+ #[test]
1020
+ fn fit_line_limit_splits_an_over_long_cont_line_in_place() {
1021
+ let mut note = Node::record("@N1@", "NOTE");
1022
+ let long_second_line = "b".repeat(80);
1023
+ let logical = format!("short first line\n{long_second_line}\nlast line");
1024
+ note.set_logical_value(&logical);
1025
+ note.push(Node::with_value("SOUR", "@S1@"));
1026
+
1027
+ assert!(note.fit_line_limit(0, 40));
1028
+ assert_eq!(note.logical_value(), logical);
1029
+ assert!(fits(&note, 40));
1030
+ // The CONT run still comes before the untouched substructure that
1031
+ // followed it.
1032
+ assert_eq!(note.children.last().map(Node::tag), Some("SOUR"));
1033
+ let tags: Vec<&str> = note.children.iter().map(Node::tag).collect();
1034
+ assert_eq!(tags.last(), Some(&"SOUR"));
1035
+ assert_eq!(tags.iter().filter(|tag| **tag == "CONT").count(), 2);
1036
+ assert!(tags.contains(&"CONC"));
1037
+ }
1038
+
1039
+ #[test]
1040
+ fn fit_line_limit_never_splits_a_pointer_payload() {
1041
+ let long_pointer = format!("@{}@", "I".repeat(300));
1042
+ let mut record = Node::record("@F1@", "FAM");
1043
+ record.push(Node::with_value("HUSB", long_pointer.clone()));
1044
+
1045
+ let husb = record.first_mut("HUSB").expect("HUSB");
1046
+ assert!(husb.pointer().is_some());
1047
+ assert!(!husb.fit_line_limit(1, MAX_LINE_LENGTH));
1048
+ assert_eq!(husb.value(), Some(long_pointer.as_str()));
1049
+ assert!(husb.all("CONC").next().is_none());
1050
+ }
1051
+
1052
+ #[test]
1053
+ fn fit_line_limit_never_touches_a_node_still_carrying_its_verbatim_line() {
1054
+ // The double space between the level and the xref is not what
1055
+ // canonical rendering would produce, so the parser keeps this
1056
+ // line's exact bytes — the mark of a node no caller has reached
1057
+ // into yet.
1058
+ let long = "y".repeat(300);
1059
+ let input = format!("0 HEAD\n0 @N1@ NOTE {long}\n0 TRLR\n");
1060
+ let mut document = Document::parse(&input).expect("parses");
1061
+ let note = document.record_mut("@N1@").expect("note");
1062
+ assert!(note.verbatim().is_some(), "kept because of the odd spacing");
1063
+
1064
+ let before = note.clone();
1065
+ assert!(!note.fit_line_limit(0, MAX_LINE_LENGTH));
1066
+ assert_eq!(*note, before, "an untouched node is not rewritten");
1067
+ assert_eq!(document.to_text(), input);
1068
+ }
1069
+
1070
+ #[test]
1071
+ fn fit_line_limit_recurses_past_an_untouched_verbatim_child() {
1072
+ // The record itself has no verbatim (it was appended fresh), but
1073
+ // its NOTE child is a kept verbatim line the caller never reached
1074
+ // into — that child must be left exactly as it was even though the
1075
+ // record around it is being fitted.
1076
+ let long = "z".repeat(300);
1077
+ let odd_note = format!("0 NOTE {long}\n");
1078
+ let reparsed = Document::parse(&format!("0 HEAD\n{odd_note}0 TRLR\n"))
1079
+ .expect("parses")
1080
+ .records
1081
+ .remove(1);
1082
+ assert!(reparsed.verbatim().is_some());
1083
+
1084
+ let mut record = Node::record("@I1@", "INDI");
1085
+ record.push(reparsed.clone());
1086
+ let mut edited_name = Node::with_value("NAME", "x".repeat(300));
1087
+ edited_name.set_logical_value(&"x".repeat(300));
1088
+ record.push(edited_name);
1089
+
1090
+ assert!(record.fit_line_limit(0, MAX_LINE_LENGTH));
1091
+ assert_eq!(
1092
+ record.children[0], reparsed,
1093
+ "the verbatim child is untouched"
1094
+ );
1095
+ assert!(
1096
+ record.children[1].all("CONC").count() > 0,
1097
+ "the edited sibling was still fitted"
1098
+ );
1099
+ }
1100
+
1101
+ #[test]
1102
+ fn fit_line_limit_covers_a_record_with_an_xref() {
1103
+ let mut record = Node::record("@I123456@", "INDI");
1104
+ record.push(Node::with_value("NOTE", "n".repeat(260)));
1105
+
1106
+ assert!(record.fit_line_limit(0, MAX_LINE_LENGTH));
1107
+ assert!(fits(&record, MAX_LINE_LENGTH));
1108
+ let note = record.first("NOTE").expect("note");
1109
+ assert_eq!(note.logical_value(), "n".repeat(260));
1110
+ }
1111
+
1112
+ #[test]
1113
+ fn document_fit_line_limit_reaches_one_record_by_xref() {
1114
+ let mut document = Document::new_v551("TEST");
1115
+ let mut note = Node::record("@N1@", "NOTE");
1116
+ note.set_logical_value(&"word ".repeat(80));
1117
+ document.append_records(vec![note]).expect("appended");
1118
+
1119
+ assert!(
1120
+ document
1121
+ .fit_line_limit("@N1@", MAX_LINE_LENGTH)
1122
+ .expect("record exists")
1123
+ );
1124
+ assert!(
1125
+ document
1126
+ .to_text()
1127
+ .lines()
1128
+ .all(|line| line.len() < MAX_LINE_LENGTH)
1129
+ );
1130
+
1131
+ let missing = document.fit_line_limit("@N9@", MAX_LINE_LENGTH);
1132
+ assert_eq!(
1133
+ missing.expect_err("no such record").kind(),
1134
+ GedcomErrorKind::Structure
1135
+ );
1136
+ }
711
1137
  }