mirror of
https://github.com/storytold/figcraft.git
synced 2026-11-14 03:01:11 +00:00
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:
@@ -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)]
|
||||
|
||||
@@ -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.");
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user