Files
pdfcraft/crates/forms/src/detect.rs
T
c0cd40f2e1 M6: forms decodes field values, renamed calculations and detected pages within shared budgets (#6132)
* M6: forms decodes values, renamed calculations and detected pages within shared budgets

Part of #5755: the forms crate's four unbudgeted stream reads go through a
`DecodeBudget`, so an operation's decoding is bounded however often the file
names the same stream.

- A reading of the form decodes its fields' value streams (/V, /DV) within one
  64 MiB budget, as it already does their scripts. Each field got a fresh
  1 GiB allowance, so 300 fields naming one stream decoded it 300 times.
- Renaming a field rewrites the calculations that read it within one script
  budget; past it, a calculation is left as it is, like one that won't decode.
- Field detection reads a page's content within one page's allowance
  (`DecodeBudget::page`), as the content walks do, however often /Contents
  lists a stream.
- The PDF/A declaration is read from at most 8 MiB of XMP, as preflight's
  Standards panel reads it, and only once /NeedAppearances isn't already set.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY

* M6: the forms README says which metadata declares PDF/A

declares_pdfa reads pdfaid:part from XMP metadata that decodes within
8 MiB, as preflight's Standards panel reads it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY

* M6: a test pins that metadata without pdfaid:part declares no PDF/A

cargo-mutants found `==` turned to `!=` in declares_pdfa's search unpinned:
no case had metadata within the cap that declares nothing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY

* M6: field detection reads operators one at a time; the PDF/A check reads XMP as preflight does

From the Resources review of 66415d737:
- page_shapes collected every parsed operator, about 80 bytes for each
  decoded byte of dense content, so a page within its 256 MiB allowance
  could take some 20 GB. It now reads them one at a time
  (pdfcraft_content::ops) and drops the pieces once joined.
- declares_pdfa decoded through a DecodeBudget, which counts every filter
  stage against the 8 MiB cap, so XMP stored through a chain of filters
  stopped declaring PDF/A while preflight's Standards panel, which caps
  each stage, still read it. It reads the metadata as preflight does.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY

* M6: say what declares PDF/A to forms, and why its metadata read stays direct

From the Legal-forms review of c00e3d2fb: the README and doc comments said
forms reads the declaration "as preflight's Standards panel reads it". The
two share the 8 MiB cap, applied to each filter stage, but forms matches the
bytes pdfaid:part and preflight the identifier by its namespace, as on main.
The sweep's direct-/DecodeParms hit is answered at the site: preflight and
organize read metadata the same way, and the readers move together.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY

* forms: rename a shared calculation once; keep unread defaults on Clear form; cap q in detection

- A Calculate action that several fields share by reference is read and
  rewritten once, in place, so every calculation reads the new name and
  the script is decoded once, not skipped past the budget.
- Clear form leaves a field alone when its /DV is a stream the value
  budget didn't reach, instead of emptying it.
- Field detection keeps at most 256 saved graphics states; deeper q are
  counted so each Q still restores its own.
- The dense-content test checks that the budget is spent; exhausted() was
  true from the start with no form visits.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* forms: restore the matrix past the save cap; reset a value that wasn't read

- Field detection keeps the matrix of the first save past MAX_SAVES and
  restores it when the last of them closes. A shape painted after a Q
  that would have undone a cm inside those saves is dropped, as the
  matrix there isn't known, instead of being reported scaled.
- Clear form skips a field only when its value is known: one whose /V
  is a stream the value budget didn't reach is reset, not kept as if it
  equalled an empty default.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* forms: keep detection's saved states in a Saves type

shapes_within went past clippy's cognitive complexity limit (32/30) with
the save-cap tracking inline. The same logic now lives in Saves (save,
restore, moved, unknown).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: FredApps <153173854+FredApps@users.noreply.github.com>
2026-11-04 13:51:40 +01:00

524 lines
20 KiB
Rust

//! Prepare a form ▸ automatic field detection: find where a printed form expects answers —
//! underscore runs, rules (horizontal lines), empty boxes, rows of comb cells and small squares —
//! and name each new field from the label next to it (Acrobat's auto-naming).
//!
//! [`detect`] is a pure function over the page's words and drawn shapes; [`page_shapes`] reads
//! the shapes from a page's content, [`raster::shapes`] from a picture of the page (a scan).
use pdfcraft_content::{Matrix, Pieces};
use pdfcraft_cos::{DecodeBudget, Document, Object};
pub mod raster;
/// A piece of text on the page, in user space.
#[derive(Clone, Debug, PartialEq)]
pub struct Word {
pub text: String,
/// [x0, y0, x1, y1], y up.
pub rect: [f64; 4],
}
/// Shapes drawn on the page, in user space: axis-aligned rectangle paths and horizontal segments.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct Shapes {
pub boxes: Vec<[f64; 4]>,
pub rules: Vec<[f64; 4]>,
}
/// What detection proposes.
#[derive(Clone, Debug, PartialEq)]
pub enum Kind {
Text,
CheckBox,
/// A row of touching cells, one character each: a text field with the comb flag and
/// `cells` as its character limit.
Comb {
cells: usize,
},
}
#[derive(Clone, Debug, PartialEq)]
pub struct Candidate {
pub kind: Kind,
pub rect: [f64; 4],
/// From the nearby label, made a valid field name.
pub name: String,
}
/// The most points a subpath may have and still be read as a rectangle: its four corners plus
/// points along its sides (#2942: a cell drawn with a midpoint on each vertical side).
const MAX_RECT_POINTS: usize = 16;
/// Graphics states saved (`q`) and kept at once while detecting; deeper saves are counted, not kept.
const MAX_SAVES: usize = 256;
/// The graphics states a page saves (`q`) as detection follows its matrix: at most [`MAX_SAVES`]
/// kept, deeper ones counted so each `Q` still closes its own. Of those not kept: the matrix when
/// the first was made, which closing the last restores; the shallowest a `cm` changed the matrix
/// in; and whether a `Q` has closed that one, so the matrix isn't known until the last closes.
struct Saves {
kept: Vec<Matrix>,
lost: usize,
lost_base: Matrix,
moved_at: usize,
unknown: bool,
}
impl Default for Saves {
fn default() -> Self {
Saves { kept: Vec::new(), lost: 0, lost_base: Matrix::IDENTITY, moved_at: usize::MAX, unknown: false }
}
}
impl Saves {
fn save(&mut self, ctm: Matrix) {
if self.kept.len() < MAX_SAVES && self.lost == 0 {
self.kept.push(ctm);
} else {
if self.lost == 0 {
self.lost_base = ctm;
}
self.lost = self.lost.saturating_add(1);
}
}
fn restore(&mut self, ctm: &mut Matrix) {
if self.lost == 0 {
*ctm = self.kept.pop().unwrap_or(*ctm);
return;
}
self.lost -= 1;
if self.lost == 0 {
(*ctm, self.moved_at, self.unknown) = (self.lost_base, usize::MAX, false);
} else if self.lost < self.moved_at {
self.unknown = true;
}
}
/// A `cm` changed the matrix at the current depth.
fn moved(&mut self) {
if self.lost > 0 {
self.moved_at = self.moved_at.min(self.lost);
}
}
}
/// The rectangles and horizontal segments a page draws (stroked or filled), in user space.
#[derive(Default)]
struct DetectionPath {
points: Vec<[f64; 2]>,
rules: Vec<[f64; 4]>,
complex: bool,
closed: bool,
}
impl DetectionPath {
fn finish(&mut self, paths: &mut Vec<Self>) {
let path = std::mem::take(self);
if !path.rules.is_empty() || (!path.complex && path.points.len() >= 4) {
paths.push(path);
}
}
fn rectangle(&self, implicitly_closed: bool) -> Option<[f64; 4]> {
if self.complex {
return None;
}
if self.points.iter().flatten().any(|v| !v.is_finite()) {
return None;
}
let near = |a: [f64; 2], b: [f64; 2]| (a[0] - b[0]).abs() < 1e-6 && (a[1] - b[1]).abs() < 1e-6;
let mut points = self.points.clone();
let repeated_end = points.len() > 1 && near(points[0], *points.last()?);
if repeated_end {
points.pop();
}
if !(self.closed || implicitly_closed || repeated_end) {
return None;
}
// A point on a straight side, between its neighbours, doesn't change the shape: drop it,
// so a cell drawn with a midpoint on a side is the rectangle it looks like (#2942). A
// point that turns back along the side is not between its neighbours and stays, so a
// path that doubles back is still not a rectangle.
let between = |a: f64, m: f64, b: f64| a.min(b) - 1e-6 <= m && m <= a.max(b) + 1e-6;
let mut i = 0;
while points.len() > 4 && i < points.len() {
let n = points.len();
let (Some(&a), Some(&m), Some(&b)) = (points.get((i + n - 1) % n), points.get(i), points.get((i + 1) % n)) else { break };
let on_row = (a[1] - m[1]).abs() < 1e-6 && (m[1] - b[1]).abs() < 1e-6 && between(a[0], m[0], b[0]);
let on_column = (a[0] - m[0]).abs() < 1e-6 && (m[0] - b[0]).abs() < 1e-6 && between(a[1], m[1], b[1]);
if on_row || on_column {
points.remove(i);
i = 0;
} else {
i += 1;
}
}
if points.len() != 4 {
return None;
}
let mut horizontal = [false; 4];
for i in 0..4 {
let (a, b) = (points[i], points[(i + 1) % 4]);
let h = (a[1] - b[1]).abs() < 1e-6;
let v = (a[0] - b[0]).abs() < 1e-6;
if h == v {
return None; // Diagonal or zero-length edge.
}
horizontal[i] = h;
}
if (0..4).any(|i| horizontal[i] == horizontal[(i + 1) % 4]) {
return None;
}
Some([
points.iter().map(|p| p[0]).fold(f64::INFINITY, f64::min),
points.iter().map(|p| p[1]).fold(f64::INFINITY, f64::min),
points.iter().map(|p| p[0]).fold(f64::NEG_INFINITY, f64::max),
points.iter().map(|p| p[1]).fold(f64::NEG_INFINITY, f64::max),
])
}
}
/// The shapes page `page`'s content draws, its streams decoded within one page's allowance
/// ([`DecodeBudget::page`]), however often `/Contents` lists the same stream.
pub fn page_shapes(doc: &Document, page: usize) -> Shapes {
shapes_within(doc, page, &mut DecodeBudget::page())
}
/// [`page_shapes`], decoding against `budget`: a stream `/Contents` lists again is charged again,
/// and one past what is left reads as empty.
pub(crate) fn shapes_within(doc: &Document, page: usize, budget: &mut DecodeBudget) -> Shapes {
let mut out = Shapes::default();
let Some(p) = pdfcraft_model::pages(doc).into_iter().nth(page) else { return out };
let list: Vec<Object> = match p.dict.get(b"Contents").map(|c| doc.resolve(c)) {
Some(c) => match &*c {
Object::Array(a) => a.clone(),
Object::Stream(_) => vec![p.dict.get(b"Contents").cloned().unwrap_or(Object::Null)],
_ => Vec::new(),
},
None => Vec::new(),
};
let mut pieces = Vec::new();
for o in list {
if let Object::Stream(s) = &*doc.resolve(&o) {
pieces.push(budget.decode(s).unwrap_or_default());
}
}
let mut ctm = Matrix::IDENTITY;
let mut saves = Saves::default();
let mut path_boxes: Vec<[f64; 4]> = Vec::new();
let mut subpaths: Vec<DetectionPath> = Vec::new();
let mut path = DetectionPath::default();
let mut cur: Option<[f64; 2]> = None;
let joined = Pieces::join(&pieces);
drop(pieces);
// An operator at a time: dense content (`n n n …`) parsed whole holds about 80 bytes for each
// byte it decoded.
for op in pdfcraft_content::ops(joined.data()) {
match op.op.as_slice() {
b"q" => saves.save(ctm),
b"Q" => saves.restore(&mut ctm),
b"cm" => {
if let Some(m) = op.nums::<6>() {
ctm = Matrix(m).then(&ctm);
saves.moved();
}
}
// Painted where the matrix isn't known: dropped, as `n` drops a path.
b"S" | b"s" | b"f" | b"F" | b"f*" | b"B" | b"B*" | b"b" | b"b*" if saves.unknown => {
path_boxes.clear();
subpaths.clear();
path = DetectionPath::default();
cur = None;
}
b"re" => {
path.finish(&mut subpaths);
if let Some([x, y, w, h]) = op.nums::<4>() {
path_boxes.push(ctm.bbox([x.min(x + w), y.min(y + h), x.max(x + w), y.max(y + h)]));
let (x, y) = ctm.apply(x, y);
cur = Some([x, y]);
path.complex = true;
path.points.push([x, y]);
}
}
b"m" => {
path.finish(&mut subpaths);
cur = op.nums::<2>().map(|[x, y]| {
let (a, b) = ctm.apply(x, y);
[a, b]
});
if let Some(p) = cur {
path.points.push(p);
}
}
b"l" => {
if let (Some(a), Some([x, y])) = (cur, op.nums::<2>()) {
// Continuing after closepath is not a single four-edge rectangle.
if path.closed {
path.complex = true;
}
let (bx, by) = ctm.apply(x, y);
let b = [bx, by];
if !path.complex {
if path.points.len() > MAX_RECT_POINTS {
path.complex = true;
} else {
if path.points.is_empty() {
path.points.push(a);
}
path.points.push(b);
}
}
if (a[1] - b[1]).abs() < 0.5 && (a[0] - b[0]).abs() > 1.0 {
path.rules.push([a[0].min(b[0]), a[1], a[0].max(b[0]), a[1]]);
}
cur = Some(b);
}
}
b"c" | b"v" | b"y" => {
path.complex = true;
let end = if op.op.as_slice() == b"c" { op.nums::<6>().map(|v| [v[4], v[5]]) } else { op.nums::<4>().map(|v| [v[2], v[3]]) };
if let Some([x, y]) = end {
let (x, y) = ctm.apply(x, y);
cur = Some([x, y]);
}
}
b"h" => {
path.closed = true;
cur = path.points.first().copied().or(cur);
}
b"S" | b"s" | b"f" | b"F" | b"f*" | b"B" | b"B*" | b"b" | b"b*" => {
if matches!(op.op.as_slice(), b"s" | b"b" | b"b*") {
path.closed = true;
}
path.finish(&mut subpaths);
let filled = !matches!(op.op.as_slice(), b"S" | b"s");
let mut path_rules = Vec::new();
for subpath in subpaths.drain(..) {
if let Some(rect) = subpath.rectangle(filled) {
path_boxes.push(rect);
} else {
path_rules.extend(subpath.rules);
}
}
for r in path_boxes.drain(..) {
// A very flat filled rectangle is a rule.
if r[3] - r[1] < 1.5 && r[2] - r[0] > 1.0 {
out.rules.push([r[0], (r[1] + r[3]) / 2.0, r[2], (r[1] + r[3]) / 2.0]);
} else {
out.boxes.push(r);
}
}
out.rules.append(&mut path_rules);
cur = None;
}
b"n" => {
path_boxes.clear();
subpaths.clear();
path = DetectionPath::default();
cur = None;
}
_ => {}
}
}
out
}
fn mid(r: [f64; 4]) -> f64 {
(r[1] + r[3]) / 2.0
}
/// A label's words made into a field name: letters, digits and spaces, no trailing colon.
fn clean(label: &str) -> String {
let s: String = label.chars().map(|c| if c.is_alphanumeric() || c == ' ' || c == '-' || c == '/' { c } else { ' ' }).collect();
let s = s.split_whitespace().collect::<Vec<_>>().join(" ");
s.chars().take(40).collect::<String>().trim().to_string()
}
/// The label for a field at `rect`: the words to its left on the same line, else the words
/// just above it.
fn label_for(words: &[Word], rect: [f64; 4], right_of: bool) -> String {
let h = (rect[3] - rect[1]).max(8.0);
let same_line = |w: &Word| (mid(w.rect) - mid(rect)).abs() < h.max(10.0) * 0.7;
let mut left: Vec<&Word> = if right_of {
words.iter().filter(|w| same_line(w) && w.rect[0] >= rect[2] - 1.0 && w.rect[0] - rect[2] < 200.0).collect()
} else {
words.iter().filter(|w| same_line(w) && w.rect[2] <= rect[0] + 1.0 && rect[0] - w.rect[2] < 250.0).collect()
};
left.sort_by(|a, b| a.rect[0].total_cmp(&b.rect[0]));
if right_of {
// The words that follow the box, up to the next gap wider than a few spaces.
let mut picked = Vec::new();
let mut x = rect[2];
for w in left {
if w.rect[0] - x > 24.0 {
break;
}
picked.push(w.text.as_str());
x = w.rect[2];
}
return clean(&picked.join(" "));
}
// The words that end nearest the field, back to a wide gap.
let mut picked: Vec<&str> = Vec::new();
let mut x = rect[0];
for w in left.iter().rev() {
if x - w.rect[2] > 24.0 && !picked.is_empty() {
break;
}
if w.text.chars().all(|c| c == '_') {
break;
}
picked.insert(0, w.text.as_str());
x = w.rect[0];
}
let l = clean(&picked.join(" "));
if !l.is_empty() {
return l;
}
// Above: the nearest line of words over the field's left part.
let above: Vec<&Word> = words
.iter()
.filter(|w| w.rect[1] >= rect[3] - 1.0 && w.rect[1] - rect[3] < 24.0 && w.rect[0] < rect[2] && w.rect[2] > rect[0] - 4.0)
.collect();
let Some(line) = above.iter().map(|w| mid(w.rect)).min_by(f64::total_cmp) else { return String::new() };
let mut on: Vec<&&Word> = above.iter().filter(|w| (mid(w.rect) - line).abs() < 3.0).collect();
on.sort_by(|a, b| a.rect[0].total_cmp(&b.rect[0]));
clean(&on.iter().map(|w| w.text.as_str()).collect::<Vec<_>>().join(" "))
}
/// Rows of two or more touching cells of one size, as (the row's rect, cell count). Two is
/// enough: dates split into day and month groups of two cells. Cells are square or taller
/// than wide; an empty table of wider cells is not a comb.
fn combs(boxes: &[[f64; 4]]) -> Vec<([f64; 4], usize)> {
let cells: Vec<[f64; 4]> = boxes
.iter()
.copied()
.filter(|r| {
let (w, h) = (r[2] - r[0], r[3] - r[1]);
(5.0..=30.0).contains(&w) && (8.0..=40.0).contains(&h) && w <= h * 1.2
})
.collect();
// `b` comes right after `a` in a row: same size, its left edge on a's right edge.
let follows = |a: &[f64; 4], b: &[f64; 4]| {
(b[0] - a[2]).abs() <= 1.0 && (b[1] - a[1]).abs() <= 0.5 && (b[3] - a[3]).abs() <= 0.5 && ((b[2] - b[0]) - (a[2] - a[0])).abs() <= 0.5
};
let mut out = Vec::new();
for first in &cells {
if cells.iter().any(|a| follows(a, first)) {
continue;
}
let (mut last, mut n) = (first, 1usize);
// Each step moves at least 4 pt right, so this ends; the bound is for safety.
while let Some(b) = cells.iter().find(|b| follows(last, b))
&& n < cells.len()
{
last = b;
n += 1;
}
if n >= 2 {
out.push(([first[0], first[1], last[2], first[3]], n));
}
}
out
}
/// Propose fields for a page. `existing` are the page's widget rectangles (no candidate
/// overlaps them); names are unique against `taken`.
pub fn detect(words: &[Word], shapes: &Shapes, existing: &[[f64; 4]], taken: &[String]) -> Vec<Candidate> {
let mut out: Vec<Candidate> = Vec::new();
let overlaps = |a: [f64; 4], b: [f64; 4]| a[0] < b[2] - 1.0 && b[0] < a[2] - 1.0 && a[1] < b[3] - 1.0 && b[1] < a[3] - 1.0;
let push = |kind: Kind, rect: [f64; 4], label: String, out: &mut Vec<Candidate>| {
if existing.iter().chain(out.iter().map(|c| &c.rect)).any(|e| overlaps(*e, rect)) {
return;
}
out.push(Candidate { kind, rect, name: label });
};
// Text that isn't underscores (labels), and underscore runs (blanks).
let (blanks, labels): (Vec<&Word>, Vec<&Word>) = words.iter().partition(|w| w.text.len() >= 3 && w.text.chars().all(|c| c == '_'));
let labels: Vec<Word> = labels.into_iter().cloned().collect();
for b in blanks {
let h = (b.rect[3] - b.rect[1]).max(10.0);
let rect = [b.rect[0], b.rect[1], b.rect[2], b.rect[1] + (h * 1.4).clamp(12.0, 22.0)];
let l = label_for(&labels, rect, false);
push(Kind::Text, rect, l, &mut out);
}
// Comb boxes (postcodes, dates, IBANs): one text field per row of cells, found before
// check boxes so the cells don't each become one.
for (rect, cells) in combs(&shapes.boxes) {
if labels.iter().any(|x| overlaps(x.rect, rect)) {
continue;
}
let l = label_for(&labels, rect, false);
push(Kind::Comb { cells }, rect, l, &mut out);
}
// Small squares: check boxes, labelled by the text to their right.
for r in &shapes.boxes {
let (w, h) = (r[2] - r[0], r[3] - r[1]);
if (5.0..=20.0).contains(&w) && (5.0..=20.0).contains(&h) && (w / h - 1.0).abs() < 0.25 {
let l = label_for(&labels, *r, true);
push(Kind::CheckBox, *r, l, &mut out);
}
}
// Empty boxes big enough to write in: text fields inside them.
for r in &shapes.boxes {
let (w, h) = (r[2] - r[0], r[3] - r[1]);
let has_text = labels.iter().any(|x| overlaps(x.rect, *r));
if w >= 40.0 && (12.0..=60.0).contains(&h) && !has_text {
let inner = [r[0] + 1.0, r[1] + 1.0, r[2] - 1.0, r[3] - 1.0];
let l = label_for(&labels, *r, false);
push(Kind::Text, inner, l, &mut out);
}
}
// Rules: a text field sitting on the line.
for r in &shapes.rules {
let w = r[2] - r[0];
if w < 40.0 {
continue;
}
// A line that underlines text (or a table border with text on it) isn't a blank.
let rect = [r[0], r[1] + 1.0, r[2], r[1] + 17.0];
if labels.iter().any(|x| overlaps(x.rect, rect)) {
continue;
}
let l = label_for(&labels, rect, false);
push(Kind::Text, rect, l, &mut out);
}
// Reading order: top to bottom, left to right.
// Rows: fields whose middles are within 8 pt of the row's first.
out.sort_by(|a, b| mid(b.rect).total_cmp(&mid(a.rect)));
let mut rows = Vec::with_capacity(out.len());
let (mut row, mut start) = (0usize, f64::INFINITY);
for c in &out {
if start - mid(c.rect) > 8.0 {
row += 1;
start = mid(c.rect);
}
rows.push(row);
}
let mut keyed: Vec<(usize, Candidate)> = rows.into_iter().zip(out).collect();
keyed.sort_by(|a, b| a.0.cmp(&b.0).then(a.1.rect[0].total_cmp(&b.1.rect[0])));
let mut out: Vec<Candidate> = keyed.into_iter().map(|(_, c)| c).collect();
// Unique names (Acrobat numbers repeats); unlabelled fields get Text1, Check Box1, …
let mut used: Vec<String> = taken.to_vec();
for c in &mut out {
let base = if c.name.is_empty() {
match c.kind {
Kind::Text | Kind::Comb { .. } => "Text".to_string(),
Kind::CheckBox => "Check Box".to_string(),
}
} else {
c.name.clone()
};
let mut name = if c.name.is_empty() { format!("{base}1") } else { base.clone() };
let mut n = 1;
while used.contains(&name) {
n += 1;
name = format!("{base}{n}");
}
used.push(name.clone());
c.name = name;
}
out
}