Merge pull request #2294 from TrevorBrown-dev/fix/fig-list-start-2273

M6: .fig import: a numbered list keeps its start only where it starts counting (#2273)
This commit is contained in:
BFlat
2026-11-13 22:41:46 +07:00
committed by GitHub
7 changed files with 160 additions and 13 deletions
+78
View File
@@ -276,6 +276,31 @@ pub enum TextDirection {
/// Deepest list nesting (levels 0..=MAX_LIST_INDENT).
pub const MAX_LIST_INDENT: u8 = 8;
/// For each paragraph, whether it is an item where a list starts counting. Numbers count the items
/// of one list at one level and restart after a plain paragraph, a shallower item or the other
/// kind of list; the first item of such a run starts counting (and takes its own start number).
/// The layout numbers by this rule.
pub fn list_item_starts_counting(lists: &[ParaList]) -> Vec<bool> {
let mut run = [ListStyle::None; MAX_LIST_INDENT as usize + 1];
lists
.iter()
.map(|pl| {
if pl.list == ListStyle::None {
run.fill(ListStyle::None);
return false;
}
let d = usize::from(pl.indent).min(run.len() - 1);
for c in run.iter_mut().skip(d + 1) {
*c = ListStyle::None;
}
let Some(c) = run.get_mut(d) else { return false };
let starts = *c != pl.list;
*c = pl.list;
starts
})
.collect()
}
/// One paragraph's list settings and base direction.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(default)]
@@ -937,6 +962,24 @@ impl TextData {
self.fold_paragraph_lists();
}
/// Keep a start number only on the item where a numbered list starts counting, and give every
/// item that goes on counting 0 (the rule the layout numbers by, [`list_item_starts_counting`]).
/// An imported file may carry the list's start on every line; once the list is split, the
/// items after the split must restart at 1, not at the old start.
pub fn keep_list_starts_where_counting_starts(&mut self) {
if self.paragraphs.iter().all(|p| p.start_offset == 0) {
return;
}
self.fill_paragraphs();
let starts = list_item_starts_counting(&self.paragraphs);
for (pl, starts) in self.paragraphs.iter_mut().zip(starts) {
if !starts {
pl.start_offset = 0;
}
}
self.fold_paragraph_lists();
}
/// One style for all paragraphs with no nesting or start folds back into `list`.
fn fold_paragraph_lists(&mut self) {
if let Some(first) = self.paragraphs.first().copied()
@@ -1251,6 +1294,41 @@ mod case_tests {
t.clear_list_starts(0, 0);
assert_eq!(t.para_list(0).start_offset, 0);
}
#[test]
fn list_starts_stay_only_where_counting_starts() {
use ListStyle::{Bulleted as B, None as P, Numbered as N};
let pl = |list, indent, start_offset| ParaList { list, indent, start_offset, ..ParaList::default() };
// (paragraphs, the starts kept, derivation)
let cases: Vec<(Vec<ParaList>, Vec<u32>, &str)> = vec![
(vec![pl(N, 0, 4), pl(N, 0, 4), pl(N, 0, 4)], vec![4, 0, 0], "items that go on counting lose theirs"),
(vec![pl(N, 0, 4), pl(P, 0, 0), pl(N, 0, 6)], vec![4, 0, 6], "a list after a plain paragraph keeps its own"),
(vec![pl(N, 0, 4), pl(B, 0, 0), pl(N, 0, 6)], vec![4, 0, 6], "and one after a bullet item"),
(
vec![pl(N, 0, 4), pl(N, 1, 2), pl(N, 1, 2), pl(N, 0, 4)],
vec![4, 2, 0, 0],
"a nested level keeps its first item's; the outer count goes on",
),
(vec![pl(N, 0, 1), pl(N, 1, 2), pl(N, 0, 9), pl(N, 1, 3)], vec![1, 2, 0, 3], "a shallower item starts the deeper count again"),
(vec![pl(N, 255, 4), pl(N, 9, 4), pl(N, 8, 4)], vec![4, 0, 0], "levels past the deepest count as the deepest"),
];
for (paragraphs, want, why) in cases {
let characters = vec!["x"; paragraphs.len()].join("\n");
let mut t = TextData { characters, paragraphs, ..TextData::new("") };
t.keep_list_starts_where_counting_starts();
let got: Vec<u32> = (0..t.paragraph_count()).map(|p| t.para_list(p).start_offset).collect();
assert_eq!(got, want, "{why}");
}
// No start anywhere: nothing is spelled out.
let mut t = TextData { list: N, ..TextData::new("a\nb") };
t.keep_list_starts_where_counting_starts();
assert!(t.paragraphs.is_empty());
// More entries than paragraphs, and an empty last paragraph.
let mut t = TextData { paragraphs: vec![pl(N, 0, 4); 5], ..TextData::new("a\n") };
t.keep_list_starts_where_counting_starts();
assert_eq!((t.para_list(0).start_offset, t.para_list(1).start_offset), (4, 0));
assert!(list_item_starts_counting(&[]).is_empty());
}
}
#[cfg(test)]
+26
View File
@@ -482,3 +482,29 @@ fn a_many_line_paste_clears_every_created_start() {
assert_eq!(d.para_list(0).start_offset, 4, "the first item keeps its start");
assert!((1..d.paragraph_count()).all(|p| d.para_list(p).start_offset == 0 && d.para_list(p).list == N));
}
#[test]
fn a_list_back_from_a_fig_split_in_the_middle_restarts_at_one() {
// #2273's steps: type "5. a", Enter, "b", Enter, "c"; save a copy as .fig and open it;
// Backspace at the start of "b". The export writes the start on every line, as Figma does,
// and "c" must then read 1., not 5.
let mut s = Session::new();
three_typed_items(&mut s, "5. a");
crate::textedit::end(&mut s).unwrap();
let (bytes, _) = figcraft_format::fig::export(s.doc().unwrap(), None).unwrap();
let (back, _) = figcraft_format::fig::import(&bytes, "x").unwrap();
let t = back.nodes().find(|n| n.kind.text().is_some()).map(|n| n.id).unwrap();
let mut s = Session::with_document(back);
let d = data(&s, t);
assert_eq!(lists(&s, t), [(N, 0); 3]);
assert_eq!(
(d.para_list(0).start_offset, d.para_list(1).start_offset, d.para_list(2).start_offset),
(4, 0, 0),
"only the first item carries the start"
);
begin(&mut s, t, Some(2), false).unwrap();
key(&mut s, "Backspace", false, false, false).unwrap();
let d = data(&s, t);
assert_eq!(lists(&s, t), [(N, 0), (P, 0), (N, 0)]);
assert_eq!((d.para_list(0).start_offset, d.para_list(2).start_offset), (4, 0), "c reads 1.");
}
+20 -2
View File
@@ -64,7 +64,7 @@ fn numbered_lists_keep_their_start() {
assert_eq!(starts(&doc, "From 1"), [(N, 0)]);
assert_eq!(starts(&doc, "From 2"), [(N, 1)], "shows 2.");
assert_eq!(starts(&doc, "First only"), [(N, 3), (N, 0), (N, 0)]);
assert_eq!(starts(&doc, "Every line"), [(N, 3), (N, 3), (N, 3)]);
assert_eq!(starts(&doc, "Every line"), [(N, 3), (N, 0), (N, 0)], "Figma writes the start on every line; only the first counts");
assert_eq!(starts(&doc, "Mixed"), [(P, 0), (N, 4), (B, 0)], "bullets have no number, so no start");
assert_eq!(starts(&doc, "Junk"), [(N, 0)], "a negative offset counts from 1");
}
@@ -89,5 +89,23 @@ fn the_export_writes_where_a_list_starts() {
// line or per list counts the same); bullets and plain lines carry none.
assert_eq!(offsets, [1.0, 1.0, 0.0, 5.0, 0.0]);
let (back, _) = import(&bytes, "x").unwrap();
assert_eq!(starts(&back, "List"), [(N, 1), (N, 1), (P, 0), (N, 5), (B, 0)]);
assert_eq!(starts(&back, "List"), [(N, 1), (N, 0), (P, 0), (N, 5), (B, 0)], "reads back as it went in");
}
#[test]
fn a_start_on_every_line_counts_only_where_the_list_starts() {
use ListStyle::{None as P, Numbered as N};
let nodes = vec![
node(0, None, "DOCUMENT", "Document", vec![]),
node(1, Some((0, "!")), "CANVAS", "Page", vec![]),
text_node(2, "Three", "a\nb\nc", vec![line("ORDERED_LIST", 4, true), line("ORDERED_LIST", 4, false), line("ORDERED_LIST", 4, false)]),
text_node(3, "Split", "a\nb\nc", vec![line("ORDERED_LIST", 4, true), line("PLAIN", 4, false), line("ORDERED_LIST", 0, true)]),
text_node(4, "Two lists", "a\nb\nc", vec![line("ORDERED_LIST", 4, true), line("PLAIN", 4, false), line("ORDERED_LIST", 6, true)]),
];
let sc = Schema::parse(LIST_SCHEMA).unwrap();
let (doc, warnings) = import(&canvas(&sc, &o(vec![("nodeChanges", arr(nodes))])), "x").unwrap();
assert!(warnings.is_empty(), "{warnings:?}");
assert_eq!(starts(&doc, "Three"), [(N, 4), (N, 0), (N, 0)]);
assert_eq!(starts(&doc, "Split"), [(N, 4), (P, 0), (N, 0)]);
assert_eq!(starts(&doc, "Two lists"), [(N, 4), (P, 0), (N, 6)], "a second list keeps its own start");
}
+20 -1
View File
@@ -675,7 +675,11 @@ fn kitchen_sink() -> Value {
]},
{"id": "k9", "name": "Sec", "type": "SECTION", "sectionContentsHidden": true, "relativeTransform": at(0.0, 0.0), "size": {"x": 50, "y": 50},
"children": [{"id": "k10", "name": "Use", "type": "INSTANCE", "componentId": "3:1", "relativeTransform": at(0.0, 0.0), "size": {"x": 24, "y": 24},
"componentProperties": {"Show#4:1": {"type": "BOOLEAN", "value": false}}}]}
"componentProperties": {"Show#4:1": {"type": "BOOLEAN", "value": false}}}]},
{"id": "k11", "name": "Listed", "type": "TEXT", "relativeTransform": at(0.0, 0.0), "size": {"x": 10, "y": 10}, "characters": "a\nb\nc\nd\ne\n",
"style": {"fontFamily": "Inter", "fontSize": 12},
"lineTypes": ["ORDERED", "ORDERED", "ORDERED", "UNORDERED", "ORDERED", "ORDERED"], "lineIndentations": [0, 0, 1, 0, 300, 0],
"listStartOffsets": [4, 4, 3, 2, 7, 9]}
]);
if let (Some(kids), Some(more)) = (v.pointer_mut("/document/children/0/children").and_then(Value::as_array_mut), extra.as_array()) {
kids.extend(more.iter().cloned());
@@ -692,6 +696,9 @@ fn kitchen_sink_imports_cleanly() {
assert!(matches!(named(&doc, "Sec").kind, NodeKind::Section { contents_hidden: true }));
assert!(matches!(&named(&doc, "Use").kind, NodeKind::Instance { props, .. } if props.get("Show") == Some(&json!(false))));
assert!(matches!(named(&doc, "Txt").kind, NodeKind::Text(ref t) if t.list == ListStyle::Numbered && t.runs.len() == 3));
let NodeKind::Text(l) = &named(&doc, "Listed").kind else { panic!("not text") };
let got: Vec<(u8, u32)> = (0..l.paragraph_count()).map(|p| (l.para_list(p).indent, l.para_list(p).start_offset)).collect();
assert_eq!(got, [(0, 4), (0, 0), (1, 3), (0, 0), (8, 7), (0, 9)], "starts stay where a count starts; the level is capped");
assert!(warnings.iter().all(|w| !w.contains("panick")), "{warnings:?}");
}
@@ -801,3 +808,15 @@ fn an_arc_without_an_end_is_whole_wherever_it_starts() {
assert_eq!(sweep, std::f64::consts::TAU, "start {start}");
}
}
#[test]
fn a_start_on_every_line_counts_only_where_the_list_starts() {
let (doc, _) = one(json!([
{"id": "t1", "name": "Every", "type": "TEXT", "relativeTransform": at(0.0, 0.0), "size": {"x": 10, "y": 10}, "characters": "a\nb\nc",
"style": {"fontFamily": "Inter", "fontSize": 12},
"lineTypes": ["ORDERED", "ORDERED", "ORDERED"], "listStartOffsets": [4, 4, 4]}
]));
let figcraft_doc::NodeKind::Text(t) = &named(&doc, "Every").kind else { panic!("not text") };
let offsets: Vec<u32> = (0..t.paragraph_count()).map(|p| t.para_list(p).start_offset).collect();
assert_eq!(offsets, [4, 0, 0]);
}
+3
View File
@@ -283,6 +283,9 @@ fn lists(v: &Value, td: &mut TextData) {
.collect();
// Clamp the levels and fold one style for all back into the layer's list style.
td.set_para_lists(0, td.char_count(), |_| {});
// Only the item where a numbered list starts counting keeps its start; Figma writes the start on
// every line of the list, and a list split later must restart at 1.
td.keep_list_starts_where_counting_starts();
}
/// The decoration's style, thickness, offset, skip ink and colour (`{value, unit}` lengths with
+10 -8
View File
@@ -1469,25 +1469,27 @@ pub(crate) fn list_markers(lists: &[ParaList]) -> Vec<String> {
// "Figma bullets will keep the same styling at all levels of indentation." (Create bulleted
// and numbered lists)
const BULLET: &str = "\u{2022}";
let mut count = [(ListStyle::None, 0usize); figcraft_doc::MAX_LIST_INDENT as usize + 1];
let starts = figcraft_doc::list_item_starts_counting(lists);
let mut count = [0usize; figcraft_doc::MAX_LIST_INDENT as usize + 1];
lists
.iter()
.map(|pl| {
.zip(starts)
.map(|(pl, starts)| {
if pl.list == ListStyle::None {
count.fill((ListStyle::None, 0));
count.fill(0);
return String::new();
}
let d = usize::from(pl.indent).min(count.len() - 1);
for c in count.iter_mut().skip(d + 1) {
*c = (ListStyle::None, 0);
*c = 0;
}
let Some(c) = count.get_mut(d) else { return String::new() };
let first = usize::try_from(pl.start_offset).unwrap_or(usize::MAX).saturating_add(1);
*c = if c.0 == pl.list { (pl.list, c.1.saturating_add(1)) } else { (pl.list, first) };
*c = if starts { first } else { c.saturating_add(1) };
match (pl.list, d % 3) {
(ListStyle::Numbered, 1) => format!("{}.", alpha(c.1)),
(ListStyle::Numbered, 2) => format!("{}.", roman(c.1)),
(ListStyle::Numbered, _) => format!("{}.", c.1),
(ListStyle::Numbered, 1) => format!("{}.", alpha(*c)),
(ListStyle::Numbered, 2) => format!("{}.", roman(*c)),
(ListStyle::Numbered, _) => format!("{}.", *c),
_ => BULLET.to_string(),
}
})
File diff suppressed because one or more lines are too long