mirror of
https://github.com/storytold/figcraft.git
synced 2026-10-31 13:56:54 -06:00
Merge fix/695
This commit is contained in:
+44
-8
@@ -215,6 +215,10 @@ pub struct ParaList {
|
||||
pub list: ListStyle,
|
||||
/// Nesting level (0 = outermost; plain paragraphs are always 0).
|
||||
pub indent: u8,
|
||||
/// A numbered list that starts counting at this paragraph counts from `start_offset + 1`
|
||||
/// (items that go on counting ignore it; other paragraphs are always 0).
|
||||
#[serde(skip_serializing_if = "crate::node::is_default")]
|
||||
pub start_offset: u32,
|
||||
}
|
||||
|
||||
/// Character style. In runs, every field is an override; `None` means "inherit the base".
|
||||
@@ -637,13 +641,13 @@ impl TextData {
|
||||
|
||||
/// Paragraph `p`'s list settings.
|
||||
pub fn para_list(&self, p: usize) -> ParaList {
|
||||
self.paragraphs.get(p).copied().unwrap_or(ParaList { list: self.list, indent: 0 })
|
||||
self.paragraphs.get(p).copied().unwrap_or(ParaList { list: self.list, ..ParaList::default() })
|
||||
}
|
||||
|
||||
/// Spell out every paragraph's list settings in `paragraphs` (and drop extra entries).
|
||||
fn fill_paragraphs(&mut self) {
|
||||
let n = self.paragraph_count();
|
||||
let fill = ParaList { list: self.list, indent: 0 };
|
||||
let fill = ParaList { list: self.list, ..ParaList::default() };
|
||||
self.paragraphs.resize(n, fill);
|
||||
}
|
||||
|
||||
@@ -657,17 +661,23 @@ impl TextData {
|
||||
}
|
||||
|
||||
/// Apply `f` to the list settings of the paragraphs chars `start..end` touch (plain
|
||||
/// paragraphs stay at level 0, levels stop at [`MAX_LIST_INDENT`]); one style for all with
|
||||
/// no nesting folds back into `list`.
|
||||
/// paragraphs stay at level 0, only numbered ones keep a start, levels stop at
|
||||
/// [`MAX_LIST_INDENT`]); one style for all with no nesting or start folds back into `list`.
|
||||
pub fn set_para_lists(&mut self, start: usize, end: usize, f: impl Fn(&mut ParaList)) {
|
||||
let (a, b) = self.para_span(start, end);
|
||||
self.fill_paragraphs();
|
||||
for pl in self.paragraphs.get_mut(a..=b).unwrap_or(&mut []) {
|
||||
f(pl);
|
||||
pl.indent = if pl.list == ListStyle::None { 0 } else { pl.indent.min(MAX_LIST_INDENT) };
|
||||
if pl.list == ListStyle::None {
|
||||
pl.indent = 0;
|
||||
}
|
||||
if pl.list != ListStyle::Numbered {
|
||||
pl.start_offset = 0;
|
||||
}
|
||||
pl.indent = pl.indent.min(MAX_LIST_INDENT);
|
||||
}
|
||||
if let Some(first) = self.paragraphs.first().copied()
|
||||
&& first.indent == 0
|
||||
&& first == (ParaList { list: first.list, ..ParaList::default() })
|
||||
&& self.paragraphs.iter().all(|p| *p == first)
|
||||
{
|
||||
self.list = first.list;
|
||||
@@ -675,11 +685,12 @@ impl TextData {
|
||||
}
|
||||
}
|
||||
|
||||
/// The list settings shared by the paragraphs chars `start..end` touch, or None when they differ.
|
||||
/// The list style and level shared by the paragraphs chars `start..end` touch (the first
|
||||
/// one's settings), or None when they differ. Start numbers don't count.
|
||||
pub fn common_para_list(&self, start: usize, end: usize) -> Option<ParaList> {
|
||||
let (a, b) = self.para_span(start, end);
|
||||
let first = self.para_list(a);
|
||||
(a..=b).all(|p| self.para_list(p) == first).then_some(first)
|
||||
(a..=b).all(|p| self.para_list(p).list == first.list && self.para_list(p).indent == first.indent).then_some(first)
|
||||
}
|
||||
|
||||
/// Link chars `start..end` (all of them when the range is empty or the whole text) to text
|
||||
@@ -782,4 +793,29 @@ mod case_tests {
|
||||
assert_eq!(st.variations.keys().collect::<Vec<_>>(), ["wdth"]);
|
||||
assert!(valid_axis_tag("wght") && !valid_axis_tag("weight"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn list_starts_load_from_old_files_and_follow_edits() {
|
||||
use ListStyle::{Bulleted as B, None as P, Numbered as N};
|
||||
// Files written before start numbers load with none, and write the same bytes.
|
||||
let old = r#"{"list":"numbered","indent":1}"#;
|
||||
let pl: ParaList = serde_json::from_str(old).unwrap();
|
||||
assert_eq!(pl, ParaList { list: N, indent: 1, start_offset: 0 });
|
||||
assert_eq!(serde_json::to_string(&pl).unwrap(), old);
|
||||
// One numbered paragraph with a start keeps it (it doesn't fold into `list`).
|
||||
let mut t = TextData { characters: "Next".into(), ..Default::default() };
|
||||
t.set_para_lists(0, 0, |p| *p = ParaList { list: N, indent: 0, start_offset: 1 });
|
||||
assert_eq!(t.para_list(0), ParaList { list: N, indent: 0, start_offset: 1 });
|
||||
// Enter copies it to the new item; different starts don't make the list mixed.
|
||||
t.replace_range(4, 4, "\nMore");
|
||||
assert_eq!(t.para_list(1).start_offset, 1);
|
||||
t.set_para_lists(5, 5, |p| p.start_offset = 0);
|
||||
assert!(t.common_para_list(0, t.char_count()).is_some_and(|p| p.list == N));
|
||||
// Bullets have no number, so no start.
|
||||
t.set_para_lists(0, 0, |p| *p = ParaList { list: B, indent: 1, start_offset: 4 });
|
||||
assert_eq!(t.para_list(0), ParaList { list: B, indent: 1, start_offset: 0 });
|
||||
// A plain paragraph has none.
|
||||
t.set_para_lists(0, 0, |p| p.list = P);
|
||||
assert_eq!(t.para_list(0), ParaList::default());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -191,7 +191,7 @@ pub fn node_json(doc: &Document, id: NodeId, depth: usize) -> Value {
|
||||
o.insert("textDecorationSkipInk".into(), json!(l.skip_ink));
|
||||
o.insert("textDecorationColor".into(), l.color.map_or(json!("auto"), |c| json!(format!("#{}", c.to_hex()))));
|
||||
}
|
||||
// Lists: one style for the layer, or each paragraph's type and nesting level.
|
||||
// Lists: one style for the layer, or each paragraph's type, nesting level and start.
|
||||
if t.paragraphs.is_empty() {
|
||||
if t.list != figcraft_doc::ListStyle::None {
|
||||
o.insert("listStyle".into(), json!(t.list));
|
||||
@@ -199,6 +199,9 @@ pub fn node_json(doc: &Document, id: NodeId, depth: usize) -> Value {
|
||||
} else {
|
||||
o.insert("lineTypes".into(), t.paragraphs.iter().map(|p| json!(p.list)).collect());
|
||||
o.insert("lineIndentations".into(), t.paragraphs.iter().map(|p| json!(p.indent)).collect());
|
||||
if t.paragraphs.iter().any(|p| p.start_offset > 0) {
|
||||
o.insert("listStartOffsets".into(), t.paragraphs.iter().map(|p| json!(p.start_offset)).collect());
|
||||
}
|
||||
}
|
||||
if t.list_spacing != 0.0 {
|
||||
o.insert("listSpacing".into(), json!(t.list_spacing));
|
||||
|
||||
@@ -57,6 +57,24 @@ fn list_buttons_act_on_the_selected_paragraphs() {
|
||||
assert_eq!(data(&s, t).list, P, "toggled off");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_list_with_a_start_number_toggles_off() {
|
||||
// An imported list that starts at 2 has the start on its first item only; it is still one
|
||||
// numbered list, so the button turns it back to plain (#686).
|
||||
let mut s = Session::new();
|
||||
let t = text(&mut s, "two\nthree\nfour");
|
||||
if let figcraft_doc::NodeKind::Text(td) = &mut s.doc_mut().unwrap().get_mut(t).unwrap().kind {
|
||||
let n = |start_offset| ParaList { list: N, indent: 0, start_offset };
|
||||
td.paragraphs = vec![n(1), n(0), n(0)];
|
||||
}
|
||||
let j = crate::inspect::node_json(s.doc().unwrap(), t, 0);
|
||||
assert_eq!(j["listStartOffsets"], json!([1, 0, 0]), "inspect reports the start");
|
||||
s.execute("text.numberList", &json!({"ids": [t]})).unwrap();
|
||||
assert_eq!(lists(&s, t), [(P, 0); 3]);
|
||||
assert_eq!(data(&s, t).para_list(0).start_offset, 0, "a plain paragraph has no start");
|
||||
assert!(crate::inspect::node_json(s.doc().unwrap(), t, 0).get("listStartOffsets").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn typed_list_starts() {
|
||||
// Typing "- " or "* " at the start of a paragraph starts a bulleted list, "1. " a numbered one
|
||||
@@ -117,7 +135,7 @@ fn list_keys_at_list_items() {
|
||||
for (chars, before, caret, k, shift, after, want, why) in cases {
|
||||
let mut s = Session::new();
|
||||
let t = text(&mut s, chars);
|
||||
let ps: Vec<ParaList> = before.iter().map(|(list, indent)| ParaList { list: *list, indent: *indent }).collect();
|
||||
let ps: Vec<ParaList> = before.iter().map(|(list, indent)| ParaList { list: *list, indent: *indent, start_offset: 0 }).collect();
|
||||
if let figcraft_doc::NodeKind::Text(td) = &mut s.doc_mut().unwrap().get_mut(t).unwrap().kind {
|
||||
td.paragraphs = ps;
|
||||
}
|
||||
@@ -141,7 +159,7 @@ fn paragraph_lists_follow_edits() {
|
||||
// A paragraph's list stays with it as text is typed, pasted and deleted: new paragraphs copy
|
||||
// the one they split from; joined paragraphs keep the first one's.
|
||||
let mut t = TextData::new("ab\ncd\nef");
|
||||
t.paragraphs = vec![ParaList { list: B, indent: 1 }, ParaList::default(), ParaList { list: N, indent: 0 }];
|
||||
t.paragraphs = vec![ParaList { list: B, indent: 1, start_offset: 0 }, ParaList::default(), ParaList { list: N, indent: 0, start_offset: 0 }];
|
||||
let get = |t: &TextData| (0..t.paragraph_count()).map(|p| (t.para_list(p).list, t.para_list(p).indent)).collect::<Vec<_>>();
|
||||
t.replace_range(1, 1, "x\ny");
|
||||
assert_eq!(get(&t), [(B, 1), (B, 1), (P, 0), (N, 0)], "a pasted newline: a new item like it");
|
||||
@@ -156,7 +174,7 @@ fn paragraph_lists_follow_edits() {
|
||||
(seed >> 33) as usize % n.max(1)
|
||||
};
|
||||
let mut t = TextData::new("a\nb\nc\nd");
|
||||
t.paragraphs = vec![ParaList { list: B, indent: 2 }; 4];
|
||||
t.paragraphs = vec![ParaList { list: B, indent: 2, start_offset: 0 }; 4];
|
||||
for _ in 0..500 {
|
||||
let n = t.char_count();
|
||||
let (a, b) = (rnd(n + 2), rnd(n + 2));
|
||||
|
||||
@@ -1565,6 +1565,20 @@ impl<'d> Exporter<'d> {
|
||||
data.extend([("characterStyleIDs", arr(ids)), ("styleOverrideTable", arr(entries))]);
|
||||
}
|
||||
let lists: Vec<ListStyle> = (0..td.paragraph_count()).map(|p| td.para_list(p).list).collect();
|
||||
let first = |i: usize, l: &ListStyle| *l != ListStyle::None && (i == 0 || lists.get(i - 1) != Some(l));
|
||||
// Where each line's numbered list starts counting, less one: its first item's start, on
|
||||
// every line of the list.
|
||||
let mut run = 0;
|
||||
let starts: Vec<u32> = lists
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, l)| {
|
||||
if first(i, l) {
|
||||
run = td.para_list(i).start_offset;
|
||||
}
|
||||
if *l == ListStyle::Numbered { run } else { 0 }
|
||||
})
|
||||
.collect();
|
||||
// One entry per paragraph, each with every field a reader expects.
|
||||
let line = |(i, l): (usize, &ListStyle)| {
|
||||
let ty = match l {
|
||||
@@ -1573,14 +1587,15 @@ impl<'d> Exporter<'d> {
|
||||
ListStyle::Bulleted => "UNORDERED_LIST",
|
||||
};
|
||||
let listed = *l != ListStyle::None;
|
||||
let first = listed && (i == 0 || lists.get(i - 1) != Some(l));
|
||||
let begins = first(i, l);
|
||||
let start = starts.get(i).copied().unwrap_or(0);
|
||||
o(vec![
|
||||
("lineType", e(ty)),
|
||||
("styleId", int(0)),
|
||||
("indentationLevel", int(i64::from(listed))),
|
||||
("sourceDirectionality", e("AUTO")),
|
||||
("listStartOffset", int(0)),
|
||||
("isFirstLineOfList", b(first)),
|
||||
("listStartOffset", int(i64::from(start))),
|
||||
("isFirstLineOfList", b(begins)),
|
||||
])
|
||||
};
|
||||
data.push(("lines", arr(lists.iter().enumerate().map(line).collect())));
|
||||
|
||||
@@ -680,7 +680,7 @@ fn normalize(n: &mut Node, ix: &dyn Fn(NodeId) -> NodeId) {
|
||||
for r in &mut td.runs {
|
||||
px(&mut r.style);
|
||||
}
|
||||
td.paragraphs = (0..td.paragraph_count()).map(|p| ParaList { list: td.para_list(p).list, indent: 0 }).collect();
|
||||
td.paragraphs = (0..td.paragraph_count()).map(|p| ParaList { indent: 0, ..td.para_list(p) }).collect();
|
||||
td.list = ListStyle::None;
|
||||
}
|
||||
NodeKind::Vector { network } => {
|
||||
|
||||
@@ -827,10 +827,8 @@ impl<'a> Mapper<'a> {
|
||||
o.insert("characterStyleOverrides".into(), json!(ids));
|
||||
o.insert("styleOverrideTable".into(), Value::Object(table));
|
||||
}
|
||||
let lines: Vec<&str> = td
|
||||
.get("lines")
|
||||
.map(Kv::list)
|
||||
.unwrap_or_default()
|
||||
let raw = td.get("lines").map(Kv::list).unwrap_or_default();
|
||||
let lines: Vec<&str> = raw
|
||||
.iter()
|
||||
.map(|l| match l.get("lineType").and_then(Kv::str) {
|
||||
Some("ORDERED_LIST") => "ORDERED",
|
||||
@@ -841,6 +839,12 @@ impl<'a> Mapper<'a> {
|
||||
if lines.iter().any(|l| *l != "NONE") {
|
||||
o.insert("lineTypes".into(), json!(lines));
|
||||
}
|
||||
// Where a numbered list starts counting, less one (not a REST field).
|
||||
let starts: Vec<u32> =
|
||||
raw.iter().map(|l| num(l, "listStartOffset").filter(|n| n.is_finite()).map_or(0, |n| n.clamp(0.0, f64::from(u32::MAX)) as u32)).collect();
|
||||
if starts.iter().any(|n| *n > 0) {
|
||||
o.insert("listStartOffsets".into(), json!(starts));
|
||||
}
|
||||
}
|
||||
|
||||
/// `fillGeometry` / `strokeGeometry` as SVG path data, and star/polygon parameters.
|
||||
|
||||
@@ -876,6 +876,9 @@ fn nested_instances_resized_again_drop_the_layout_derived_for_their_old_size() {
|
||||
#[path = "tail_tests.rs"]
|
||||
mod tail;
|
||||
|
||||
#[path = "text_list_tests.rs"]
|
||||
mod text_list;
|
||||
|
||||
/// `.fig` files in the folder `FIGCRAFT_FIG_SAMPLES` names (none when it's unset).
|
||||
pub(super) fn samples() -> Vec<std::path::PathBuf> {
|
||||
let Some(dir) = std::env::var_os("FIGCRAFT_FIG_SAMPLES") else { return Vec::new() };
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
//! Numbered lists that start past 1 (#686): each text line says where its list starts counting,
|
||||
//! less one (`listStartOffset`). Fixtures built in code with the parent module's helpers.
|
||||
|
||||
use figcraft_doc::{ListStyle, NodeKind, ParaList};
|
||||
|
||||
use super::*;
|
||||
|
||||
const LIST_SCHEMA: &str = "
|
||||
enum NodeType NONE DOCUMENT CANVAS TEXT
|
||||
enum LineType PLAIN ORDERED_LIST UNORDERED_LIST
|
||||
struct GUID uint sessionID, uint localID
|
||||
struct Vector float x, float y
|
||||
struct Matrix float m00, float m01, float m02, float m10, float m11, float m12
|
||||
message ParentIndex GUID guid, string position
|
||||
message TextLineData LineType lineType, int indentationLevel, int listStartOffset, bool isFirstLineOfList
|
||||
message TextData string characters, TextLineData[] lines
|
||||
message NodeChange GUID guid, ParentIndex parentIndex, NodeType type, string name, Vector size, Matrix transform, float fontSize, TextData textData
|
||||
message Message NodeChange[] nodeChanges
|
||||
";
|
||||
|
||||
/// A list line: type, start offset, first of its list.
|
||||
fn line(ty: &'static str, offset: i64, first: bool) -> Kv<'static> {
|
||||
let level = i64::from(ty != "PLAIN");
|
||||
o(vec![("lineType", e(ty)), ("indentationLevel", Kv::Int(level)), ("listStartOffset", Kv::Int(offset)), ("isFirstLineOfList", Kv::Bool(first))])
|
||||
}
|
||||
|
||||
fn text_node(id: u64, name: &str, chars: &str, lines: Vec<Kv<'static>>) -> Kv<'static> {
|
||||
let pos = ["!", "\"", "#", "$", "%", "&"][id as usize % 6];
|
||||
node(
|
||||
id,
|
||||
Some((1, pos)),
|
||||
"TEXT",
|
||||
name,
|
||||
vec![
|
||||
("size", xy(200.0, 60.0)),
|
||||
("transform", at(0.0, 0.0)),
|
||||
("fontSize", f(16.0)),
|
||||
("textData", o(vec![("characters", s(chars)), ("lines", arr(lines))])),
|
||||
],
|
||||
)
|
||||
}
|
||||
|
||||
fn starts(doc: &figcraft_doc::Document, name: &str) -> Vec<(ListStyle, u32)> {
|
||||
let NodeKind::Text(t) = &named(doc, name).kind else { panic!("{name} is no text") };
|
||||
(0..t.paragraph_count()).map(|p| t.para_list(p)).map(|p: ParaList| (p.list, p.start_offset)).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn numbered_lists_keep_their_start() {
|
||||
use ListStyle::{Bulleted as B, 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, "From 1", "Start here", vec![line("ORDERED_LIST", 0, true)]),
|
||||
text_node(3, "From 2", "Next try this", vec![line("ORDERED_LIST", 1, true)]),
|
||||
text_node(4, "First only", "a\nb\nc", vec![line("ORDERED_LIST", 3, true), line("ORDERED_LIST", 0, false), line("ORDERED_LIST", 0, false)]),
|
||||
text_node(5, "Every line", "a\nb\nc", vec![line("ORDERED_LIST", 3, true), line("ORDERED_LIST", 3, false), line("ORDERED_LIST", 3, false)]),
|
||||
text_node(6, "Mixed", "x\na\nb", vec![line("PLAIN", 0, false), line("ORDERED_LIST", 4, true), line("UNORDERED_LIST", 2, true)]),
|
||||
text_node(7, "Junk", "a", vec![line("ORDERED_LIST", -5, 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, "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, "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");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_export_writes_where_a_list_starts() {
|
||||
let mut doc = figcraft_doc::Document::new("Lists");
|
||||
let page = doc.first_page().unwrap();
|
||||
use ListStyle::{Bulleted as B, None as P, Numbered as N};
|
||||
let mut td = figcraft_doc::TextData::new("two\nthree\nplain\nsix\ndot");
|
||||
let pl = |list, start_offset| ParaList { list, indent: 0, start_offset };
|
||||
td.paragraphs = vec![pl(N, 1), pl(N, 0), pl(P, 0), pl(N, 5), pl(B, 4)];
|
||||
doc.create(page, NodeKind::Text(td), "List", 0.0, 0.0, 200.0, 80.0).unwrap();
|
||||
let (bytes, _) = super::super::export(&doc, None).unwrap();
|
||||
let a = Archive::open(&bytes).unwrap();
|
||||
let (schema, message) = decode_canvas(&a.canvas).unwrap();
|
||||
let msg = schema.decode_message(schema.find("Message").unwrap(), &message).unwrap();
|
||||
let text = msg.get("nodeChanges").unwrap().list().iter().find(|n| n.get("name").and_then(Kv::str) == Some("List")).unwrap();
|
||||
let lines = text.get("textData").and_then(|d| d.get("lines")).map(Kv::list).unwrap();
|
||||
let offsets: Vec<f64> = lines.iter().map(|l| l.get("listStartOffset").and_then(Kv::f64).unwrap()).collect();
|
||||
// Every line of a numbered list carries its first item's start (a reader that takes it per
|
||||
// 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)]);
|
||||
}
|
||||
@@ -210,10 +210,12 @@ fn runs(cx: &mut Ctx, v: &Value, td: &TextData) -> Vec<StyleRun> {
|
||||
out
|
||||
}
|
||||
|
||||
/// Each paragraph's list type and nesting level from `lineTypes` and `lineIndentations`.
|
||||
/// Each paragraph's list type, nesting level and start number from `lineTypes`,
|
||||
/// `lineIndentations` and (from `.fig` files) `listStartOffsets`.
|
||||
fn lists(v: &Value, td: &mut TextData) {
|
||||
let types = list(v, "lineTypes");
|
||||
let indents = list(v, "lineIndentations");
|
||||
let starts = list(v, "listStartOffsets");
|
||||
td.paragraphs = types
|
||||
.iter()
|
||||
.enumerate()
|
||||
@@ -224,6 +226,7 @@ fn lists(v: &Value, td: &mut TextData) {
|
||||
_ => ListStyle::None,
|
||||
},
|
||||
indent: indents.get(i).and_then(Value::as_f64).filter(|d| d.is_finite()).map_or(0, |d| d.clamp(0.0, 255.0) as u8),
|
||||
start_offset: starts.get(i).and_then(Value::as_f64).filter(|n| n.is_finite()).map_or(0, |n| n.clamp(0.0, f64::from(u32::MAX)) as u32),
|
||||
})
|
||||
.collect();
|
||||
// Clamp the levels and fold one style for all back into the layer's list style.
|
||||
|
||||
@@ -827,8 +827,8 @@ pub fn layout(text: &TextData, box_width: Option<f64>, box_height: Option<f64>)
|
||||
}
|
||||
|
||||
/// The markers of a text's paragraphs ("" for plain ones). Numbers count the items of one list at
|
||||
/// one level, restarting after a plain paragraph, a shallower item or the other kind of list;
|
||||
/// levels go 1. a. i. and • ◦ ▪, then round again.
|
||||
/// one level, restarting after a plain paragraph, a shallower item or the other kind of list,
|
||||
/// from the first item's start number; levels go 1. a. i. and • ◦ ▪, then round again.
|
||||
pub(crate) fn list_markers(lists: &[ParaList]) -> Vec<String> {
|
||||
const BULLETS: [&str; 3] = ["\u{2022}", "\u{25E6}", "\u{25AA}"];
|
||||
let mut count = [(ListStyle::None, 0usize); figcraft_doc::MAX_LIST_INDENT as usize + 1];
|
||||
@@ -844,7 +844,8 @@ pub(crate) fn list_markers(lists: &[ParaList]) -> Vec<String> {
|
||||
*c = (ListStyle::None, 0);
|
||||
}
|
||||
let Some(c) = count.get_mut(d) else { return String::new() };
|
||||
*c = if c.0 == pl.list { (pl.list, c.1 + 1) } else { (pl.list, 1) };
|
||||
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) };
|
||||
match (pl.list, d % 3) {
|
||||
(ListStyle::Numbered, 1) => format!("{}.", alpha(c.1)),
|
||||
(ListStyle::Numbered, 2) => format!("{}.", roman(c.1)),
|
||||
|
||||
@@ -43,7 +43,32 @@ fn starts(l: &TextLayout) -> Vec<(f64, Option<f64>)> {
|
||||
}
|
||||
|
||||
fn pl(list: ListStyle, indent: u8) -> ParaList {
|
||||
ParaList { list, indent }
|
||||
ParaList { list, indent, start_offset: 0 }
|
||||
}
|
||||
|
||||
fn from(list: ListStyle, indent: u8, start_offset: u32) -> ParaList {
|
||||
ParaList { list, indent, start_offset }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn numbered_lists_count_from_their_start() {
|
||||
use ListStyle::{Bulleted as B, None as P, Numbered as N};
|
||||
let cases: Vec<(Vec<ParaList>, Vec<&str>, &str)> = vec![
|
||||
(vec![from(N, 0, 1)], vec!["2."], "a one-item list started at 2 (#686)"),
|
||||
(vec![from(N, 0, 3), pl(N, 0), pl(N, 0)], vec!["4.", "5.", "6."], "the items after the first count on from it"),
|
||||
(vec![from(N, 0, 3), from(N, 0, 3), from(N, 0, 3)], vec!["4.", "5.", "6."], "items that go on counting ignore their own start"),
|
||||
(vec![from(N, 0, 1), pl(P, 0), from(N, 0, 1)], vec!["2.", "", "2."], "a list after a plain paragraph starts from its own start"),
|
||||
(vec![pl(N, 0), from(N, 1, 1), pl(N, 1)], vec!["1.", "b.", "c."], "a nested level starts from its first item's start"),
|
||||
(vec![from(B, 0, 4), pl(B, 0)], vec!["\u{2022}", "\u{2022}"], "bullets have no number"),
|
||||
(vec![from(N, 0, 4_000), pl(N, 2)], vec!["4001.", "i."], "big starts"),
|
||||
];
|
||||
for (lists, want, why) in cases {
|
||||
assert_eq!(list_markers(&lists), want, "{why}");
|
||||
}
|
||||
// The largest start never overflows, on 32-bit targets too.
|
||||
let max = list_markers(&[from(N, 0, u32::MAX), pl(N, 0), pl(N, 0)]);
|
||||
assert_eq!(max.len(), 3);
|
||||
assert!(max.iter().all(|m| m.starts_with("429496729")), "{max:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
+2
-1
@@ -179,7 +179,8 @@ Or in a project's `.mcp.json`:
|
||||
- **Lists** are per paragraph: `listStyle` (`none`, `bulleted`, `numbered`) and `indentation` (the
|
||||
nesting level, 0–8) apply to the paragraphs chars `start`..`end` touch (all of them without a
|
||||
range); `get_node` reports one `listStyle` for the layer, or `lineTypes` and `lineIndentations`
|
||||
per paragraph. `text.bulletList`, `text.numberList` and `text.listStyle` act on the selected
|
||||
per paragraph, plus `listStartOffsets` when a numbered list starts past 1 (from a `.fig` file;
|
||||
1 shows "2."). `text.bulletList`, `text.numberList` and `text.listStyle` act on the selected
|
||||
paragraphs while text is edited. Layer props: `listSpacing` (px between list items, in place of
|
||||
`paragraphSpacing`), `hangingList` (markers outside the box) and `hangingPunctuation`. While
|
||||
editing, `key` Tab / Shift+Tab nest an item or move it out, Enter on an empty item and Backspace
|
||||
|
||||
Reference in New Issue
Block a user