use std::collections::BTreeMap;
use std::fmt::Write as _;
use google_docs1::api::{
CreateParagraphBulletsRequest, Dimension, InsertTableRequest, InsertTextRequest, Link,
Location, ParagraphStyle, Range, Request, TextStyle, UpdateParagraphStyleRequest,
UpdateTextStyleRequest, WeightedFontFamily,
};
use google_docs1::common::FieldMask;
use kb::ast::{Block, Checkbox, Document, Inline, ListItem, ListType, TableCell, Title};
use sha2::{Digest, Sha256};
use crate::custom_id::section_id;
use crate::google::utf16::len_utf16;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ElementKind {
Heading,
Paragraph,
List,
Table,
SrcBlock,
ExampleBlock,
Quote,
HorizontalRule,
}
impl ElementKind {
#[must_use]
pub const fn slug(self) -> &'static str {
match self {
Self::Heading => "heading",
Self::Paragraph => "paragraph",
Self::List => "list",
Self::Table => "table",
Self::SrcBlock => "src",
Self::ExampleBlock => "example",
Self::Quote => "quote",
Self::HorizontalRule => "hr",
}
}
#[must_use]
pub fn from_slug(slug: &str) -> Option<Self> {
match slug {
"heading" => Some(Self::Heading),
"paragraph" => Some(Self::Paragraph),
"list" => Some(Self::List),
"table" => Some(Self::Table),
"src" => Some(Self::SrcBlock),
"example" => Some(Self::ExampleBlock),
"quote" => Some(Self::Quote),
"hr" => Some(Self::HorizontalRule),
_ => None,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Position {
pub index: u32,
pub kind: ElementKind,
}
pub type PositionMap = BTreeMap<String, Position>;
#[derive(Debug, Clone)]
pub struct Projection {
pub requests: Vec<Request>,
pub positions: PositionMap,
}
impl Projection {
#[must_use]
pub fn fingerprint(&self) -> String {
let serialized = serde_json::to_vec(&self.requests).unwrap_or_default();
let mut hasher = Sha256::new();
hasher.update(&serialized);
hasher
.finalize()
.iter()
.fold(String::new(), |mut acc, byte| {
let _ = write!(acc, "{byte:02x}");
acc
})
}
}
pub(crate) const DOCUMENT_PARENT: &str = "doc";
const MONOSPACE_FONT: &str = "Courier New";
const RULE_GLYPHS: &str = "────────────────────";
const MARK_UNCHECKED: &str = "\u{2610} ";
const MARK_CHECKED: &str = "\u{2611} ";
#[must_use]
pub fn project(document: &Document) -> Projection {
let mut builder = Builder::new();
builder.blocks(&document.blocks, DOCUMENT_PARENT);
Projection {
requests: builder.requests,
positions: builder.positions,
}
}
struct Builder {
requests: Vec<Request>,
positions: PositionMap,
cursor: u32,
counters: BTreeMap<String, u32>,
}
impl Builder {
fn new() -> Self {
Self {
requests: Vec::new(),
positions: PositionMap::new(),
cursor: 1,
counters: BTreeMap::new(),
}
}
fn blocks(&mut self, blocks: &[Block], parent: &str) {
for block in blocks {
self.block(block, parent);
}
}
fn block(&mut self, block: &Block, parent: &str) {
match block {
Block::Heading {
level,
title,
children,
..
} => self.heading(*level, title, children),
Block::Paragraph { inlines } => self.paragraph(inlines, parent),
Block::SrcBlock { content, .. } => {
self.monospace_block(content, parent, ElementKind::SrcBlock);
}
Block::ExampleBlock { content } => {
self.monospace_block(content, parent, ElementKind::ExampleBlock);
}
Block::QuoteBlock { children } => self.quote(children, parent),
Block::List { list_type, items } => self.list(list_type, items, parent),
Block::Table { rows } => self.table(rows, parent),
Block::HorizontalRule => self.horizontal_rule(parent),
Block::PropertyDrawer { .. }
| Block::LogbookDrawer { .. }
| Block::Planning { .. }
| Block::Comment { .. }
| Block::Keyword { .. }
| Block::BlankLine => {}
}
}
fn heading(&mut self, level: u8, title: &Title, children: &[Block]) {
let id = heading_id(title, children);
let start = self.cursor;
self.record(&id, ElementKind::Heading, start);
let (range_start, range_end) = self.insert_line(title.as_str());
self.push(named_style_request(
range_start,
range_end,
&heading_style(level),
));
self.blocks(children, &id);
}
fn paragraph(&mut self, inlines: &[Inline], parent: &str) {
let id = self.next_id(parent, ElementKind::Paragraph);
let start = self.cursor;
self.record(&id, ElementKind::Paragraph, start);
let (text, spans) = flatten(inlines);
let (range_start, range_end) = self.insert_line(&text);
self.push(named_style_request(range_start, range_end, "NORMAL_TEXT"));
self.styles(range_start, &spans);
}
fn monospace_block(&mut self, content: &str, parent: &str, kind: ElementKind) {
let id = self.next_id(parent, kind);
let start = self.cursor;
self.record(&id, kind, start);
let (range_start, range_end) = self.insert_line(content);
self.push(named_style_request(range_start, range_end, "NORMAL_TEXT"));
self.push(text_style_request(
range_start,
range_end,
&Style::Monospace,
));
}
fn quote(&mut self, children: &[Block], parent: &str) {
let id = self.next_id(parent, ElementKind::Quote);
let start = self.cursor;
self.record(&id, ElementKind::Quote, start);
for child in children {
if let Block::Paragraph { inlines } = child {
let (text, spans) = flatten(inlines);
let (range_start, range_end) = self.insert_line(&text);
self.push(indented_style_request(range_start, range_end));
self.styles(range_start, &spans);
} else {
self.block(child, &id);
}
}
}
fn list(&mut self, list_type: &ListType, items: &[ListItem], parent: &str) {
let id = self.next_id(parent, ElementKind::List);
let start = self.cursor;
self.record(&id, ElementKind::List, start);
let has_checkbox = items
.iter()
.any(|item| !matches!(item.checkbox, Checkbox::NoCheckbox));
let mut joined = String::new();
let mut spans: Vec<Span> = Vec::new();
for item in items {
let base = len_u32(&joined);
let prefix = checkbox_marker(&item.checkbox);
joined.push_str(prefix);
let item_base = base + len_u32(prefix);
let (text, item_spans) = flatten_blocks(&item.content);
for span in item_spans {
spans.push(span.shifted(item_base));
}
joined.push_str(&text);
joined.push('\n');
}
let range_start = self.cursor;
let range_end = self.emit_text(joined);
if !has_checkbox {
self.push(bullets_request(range_start, range_end, list_type));
}
self.styles(range_start, &spans);
}
fn table(&mut self, rows: &[Vec<TableCell>], parent: &str) {
let id = self.next_id(parent, ElementKind::Table);
let start = self.cursor;
self.record(&id, ElementKind::Table, start);
let data_rows: Vec<&Vec<TableCell>> = rows.iter().filter(|row| !is_rule_row(row)).collect();
let row_count = len_u32_of(data_rows.len());
let col_count = len_u32_of(data_rows.iter().map(|row| row.len()).max().unwrap_or(0));
if row_count == 0 || col_count == 0 {
return;
}
self.push(insert_table_request(start, row_count, col_count));
let mut cells: Vec<(u32, String)> = Vec::new();
let mut text_units = 0u32;
for (row_index, row) in data_rows.iter().enumerate() {
for (col_index, cell) in row.iter().enumerate() {
let text = flatten(&cell.inlines).0;
if text.is_empty() {
continue;
}
let index = cell_content_index(
start,
len_u32_of(row_index),
len_u32_of(col_index),
col_count,
);
text_units = text_units.saturating_add(len_u32(&text));
cells.push((index, text));
}
}
cells.sort_by_key(|cell| core::cmp::Reverse(cell.0));
for (index, text) in cells {
self.push(insert_text_request(index, text));
}
self.cursor =
index_after_empty_table(start, row_count, col_count).saturating_add(text_units);
}
fn horizontal_rule(&mut self, parent: &str) {
let id = self.next_id(parent, ElementKind::HorizontalRule);
let start = self.cursor;
self.record(&id, ElementKind::HorizontalRule, start);
let (range_start, range_end) = self.insert_line(RULE_GLYPHS);
self.push(named_style_request(range_start, range_end, "NORMAL_TEXT"));
}
fn insert_line(&mut self, text: &str) -> (u32, u32) {
let start = self.cursor;
let mut line = String::with_capacity(text.len() + 1);
line.push_str(text);
line.push('\n');
let end = self.emit_text(line);
(start, end)
}
fn emit_text(&mut self, text: String) -> u32 {
let start = self.cursor;
let end = start.saturating_add(len_u32(&text));
self.push(insert_text_request(start, text));
self.cursor = end;
end
}
fn styles(&mut self, base: u32, spans: &[Span]) {
for span in spans {
let start = base.saturating_add(span.start);
let end = base.saturating_add(span.end);
if end <= start {
continue;
}
self.push(text_style_request(start, end, &span.style));
}
}
fn record(&mut self, id: &str, kind: ElementKind, index: u32) {
self.positions
.insert(id.to_owned(), Position { index, kind });
}
fn next_id(&mut self, parent: &str, kind: ElementKind) -> String {
let key = format!("{parent}/{}", kind.slug());
let count = self.counters.entry(key).or_insert(0);
*count = count.saturating_add(1);
format!("{parent}/{}-{count}", kind.slug())
}
fn push(&mut self, request: Request) {
self.requests.push(request);
}
}
struct Span {
style: Style,
start: u32,
end: u32,
}
impl Span {
fn shifted(self, offset: u32) -> Self {
Self {
style: self.style,
start: self.start.saturating_add(offset),
end: self.end.saturating_add(offset),
}
}
}
enum Style {
Bold,
Italic,
Strikethrough,
Monospace,
Link(String),
}
pub(crate) fn inline_text(inlines: &[Inline]) -> String {
flatten(inlines).0
}
fn flatten(inlines: &[Inline]) -> (String, Vec<Span>) {
let mut text = String::new();
let mut spans = Vec::new();
flatten_into(inlines, &mut text, &mut spans);
(text, spans)
}
fn flatten_blocks(blocks: &[Block]) -> (String, Vec<Span>) {
let mut text = String::new();
let mut spans = Vec::new();
collect_block_inlines(blocks, &mut text, &mut spans);
(text, spans)
}
fn collect_block_inlines(blocks: &[Block], text: &mut String, spans: &mut Vec<Span>) {
for block in blocks {
match block {
Block::Paragraph { inlines } => {
if !text.is_empty() {
text.push(' ');
}
flatten_into(inlines, text, spans);
}
Block::List { items, .. } => {
for item in items {
collect_block_inlines(&item.content, text, spans);
}
}
_ => {}
}
}
}
fn flatten_into(inlines: &[Inline], text: &mut String, spans: &mut Vec<Span>) {
for inline in inlines {
match inline {
Inline::Plain(value) => text.push_str(value),
Inline::Bold(children) => wrap(children, Style::Bold, text, spans),
Inline::Italic(children) => wrap(children, Style::Italic, text, spans),
Inline::Strikethrough(children) => wrap(children, Style::Strikethrough, text, spans),
Inline::InlineCode(value) | Inline::Verbatim(value) => {
push_span(value, Style::Monospace, text, spans);
}
Inline::LineBreak => {
if !text.ends_with(char::is_whitespace) {
text.push(' ');
}
}
Inline::Link {
target,
description,
} => {
let visible = description.as_deref().unwrap_or(target);
push_span(visible, Style::Link(target.clone()), text, spans);
}
}
}
}
fn wrap(children: &[Inline], style: Style, text: &mut String, spans: &mut Vec<Span>) {
let start = len_u32(text);
flatten_into(children, text, spans);
let end = len_u32(text);
spans.push(Span { style, start, end });
}
fn push_span(value: &str, style: Style, text: &mut String, spans: &mut Vec<Span>) {
let start = len_u32(text);
text.push_str(value);
let end = len_u32(text);
spans.push(Span { style, start, end });
}
pub(crate) fn heading_id(title: &Title, children: &[Block]) -> String {
children
.iter()
.find_map(|block| match block {
Block::PropertyDrawer { entries } => entries.iter().find_map(|(key, value)| {
key.eq_ignore_ascii_case("CUSTOM_ID")
.then(|| value.trim().to_owned())
}),
_ => None,
})
.filter(|id| !id.is_empty())
.unwrap_or_else(|| section_id(title.as_str()))
}
fn heading_style(level: u8) -> String {
format!("HEADING_{}", level.clamp(1, 6))
}
const fn checkbox_marker(checkbox: &Checkbox) -> &'static str {
match checkbox {
Checkbox::Unchecked => MARK_UNCHECKED,
Checkbox::Checked => MARK_CHECKED,
Checkbox::NoCheckbox => "",
}
}
fn is_rule_row(row: &[TableCell]) -> bool {
!row.is_empty()
&& row.iter().all(|cell| {
let text = inline_text(&cell.inlines);
let trimmed = text.trim();
!trimmed.is_empty() && trimmed.chars().all(|c| matches!(c, '-' | '+' | '|'))
})
}
const fn cell_content_index(table_loc: u32, row: u32, col: u32, cols: u32) -> u32 {
table_loc + 4 + row * (1 + 2 * cols) + col * 2
}
const fn index_after_empty_table(table_loc: u32, rows: u32, cols: u32) -> u32 {
table_loc + 3 + rows * (1 + 2 * cols)
}
fn location(index: u32) -> Location {
Location {
index: Some(to_i32(index)),
segment_id: None,
tab_id: None,
}
}
fn range(start: u32, end: u32) -> Range {
Range {
start_index: Some(to_i32(start)),
end_index: Some(to_i32(end)),
segment_id: None,
tab_id: None,
}
}
fn insert_text_request(index: u32, text: String) -> Request {
Request {
insert_text: Some(InsertTextRequest {
location: Some(location(index)),
text: Some(text),
end_of_segment_location: None,
}),
..Request::default()
}
}
fn insert_table_request(location_index: u32, rows: u32, cols: u32) -> Request {
Request {
insert_table: Some(InsertTableRequest {
location: Some(location(location_index)),
rows: Some(to_i32(rows)),
columns: Some(to_i32(cols)),
end_of_segment_location: None,
}),
..Request::default()
}
}
fn named_style_request(start: u32, end: u32, named_style: &str) -> Request {
Request {
update_paragraph_style: Some(UpdateParagraphStyleRequest {
range: Some(range(start, end)),
paragraph_style: Some(ParagraphStyle {
named_style_type: Some(named_style.to_owned()),
..ParagraphStyle::default()
}),
fields: Some(FieldMask::new(&["namedStyleType"])),
}),
..Request::default()
}
}
fn indented_style_request(start: u32, end: u32) -> Request {
Request {
update_paragraph_style: Some(UpdateParagraphStyleRequest {
range: Some(range(start, end)),
paragraph_style: Some(ParagraphStyle {
named_style_type: Some("NORMAL_TEXT".to_owned()),
indent_start: Some(Dimension {
magnitude: Some(36.0),
unit: Some("PT".to_owned()),
}),
..ParagraphStyle::default()
}),
fields: Some(FieldMask::new(&["namedStyleType", "indentStart"])),
}),
..Request::default()
}
}
fn bullets_request(start: u32, end: u32, list_type: &ListType) -> Request {
let preset = match list_type {
ListType::Ordered(_) => "NUMBERED_DECIMAL_ALPHA_ROMAN",
ListType::Unordered => "BULLET_DISC_CIRCLE_SQUARE",
};
Request {
create_paragraph_bullets: Some(CreateParagraphBulletsRequest {
range: Some(range(start, end)),
bullet_preset: Some(preset.to_owned()),
}),
..Request::default()
}
}
fn text_style_request(start: u32, end: u32, style: &Style) -> Request {
let (text_style, fields) = match style {
Style::Bold => (
TextStyle {
bold: Some(true),
..TextStyle::default()
},
"bold",
),
Style::Italic => (
TextStyle {
italic: Some(true),
..TextStyle::default()
},
"italic",
),
Style::Strikethrough => (
TextStyle {
strikethrough: Some(true),
..TextStyle::default()
},
"strikethrough",
),
Style::Monospace => (
TextStyle {
weighted_font_family: Some(WeightedFontFamily {
font_family: Some(MONOSPACE_FONT.to_owned()),
weight: Some(400),
}),
..TextStyle::default()
},
"weightedFontFamily",
),
Style::Link(url) => (
TextStyle {
link: Some(Link {
url: Some(url.clone()),
..Link::default()
}),
..TextStyle::default()
},
"link",
),
};
Request {
update_text_style: Some(UpdateTextStyleRequest {
range: Some(range(start, end)),
text_style: Some(text_style),
fields: Some(FieldMask::new(&[fields])),
}),
..Request::default()
}
}
fn len_u32(text: &str) -> u32 {
u32::try_from(len_utf16(text)).unwrap_or(u32::MAX)
}
fn len_u32_of(value: usize) -> u32 {
u32::try_from(value).unwrap_or(u32::MAX)
}
fn to_i32(value: u32) -> i32 {
i32::try_from(value).unwrap_or(i32::MAX)
}
#[cfg(test)]
mod tests {
use super::{ElementKind, Style, project, text_style_request};
use kb::ast::{Block, Checkbox, Document, Inline, ListItem, ListType, TableCell, Title};
fn heading(level: u8, title: &str, children: Vec<Block>) -> Block {
Block::Heading {
level,
title: Title(title.to_owned()),
tags: vec![],
children,
}
}
fn custom_id_drawer(id: &str) -> Block {
Block::PropertyDrawer {
entries: vec![("CUSTOM_ID".to_owned(), format!(" {id}"))],
}
}
fn paragraph(inlines: Vec<Inline>) -> Block {
Block::Paragraph { inlines }
}
fn plain(text: &str) -> Inline {
Inline::Plain(text.to_owned())
}
fn doc(blocks: Vec<Block>) -> Document {
Document { blocks }
}
#[test]
fn heading_emits_insert_then_named_style() {
let projection = project(&doc(vec![heading(2, "Hi", vec![])]));
assert_eq!(projection.requests.len(), 2);
let insert = projection.requests[0].insert_text.as_ref().expect("insert");
assert_eq!(insert.text.as_deref(), Some("Hi\n"));
assert_eq!(insert.location.as_ref().and_then(|l| l.index), Some(1));
let style = projection.requests[1]
.update_paragraph_style
.as_ref()
.expect("style");
assert_eq!(
style
.paragraph_style
.as_ref()
.and_then(|p| p.named_style_type.as_deref()),
Some("HEADING_2")
);
let range = style.range.as_ref().expect("range");
assert_eq!((range.start_index, range.end_index), (Some(1), Some(4)));
}
#[test]
fn heading_level_clamped_to_six() {
let projection = project(&doc(vec![heading(9, "Deep", vec![])]));
let style = projection.requests[1]
.update_paragraph_style
.as_ref()
.unwrap();
assert_eq!(
style
.paragraph_style
.as_ref()
.and_then(|p| p.named_style_type.as_deref()),
Some("HEADING_6")
);
}
#[test]
fn paragraph_nested_bold_italic_and_link() {
let para = paragraph(vec![
plain("a "),
Inline::Bold(vec![Inline::Italic(vec![plain("bc")])]),
plain(" "),
Inline::Link {
target: "https://x".to_owned(),
description: Some("d".to_owned()),
},
]);
let projection = project(&doc(vec![para]));
let insert = projection.requests[0].insert_text.as_ref().unwrap();
assert_eq!(insert.text.as_deref(), Some("a bc d\n"));
assert_eq!(
projection.requests[1]
.update_paragraph_style
.as_ref()
.and_then(|s| s.paragraph_style.as_ref())
.and_then(|p| p.named_style_type.as_deref()),
Some("NORMAL_TEXT")
);
let italic = projection.requests[2].update_text_style.as_ref().unwrap();
assert_eq!(
italic.text_style.as_ref().and_then(|t| t.italic),
Some(true)
);
let italic_range = italic.range.as_ref().unwrap();
assert_eq!(
(italic_range.start_index, italic_range.end_index),
(Some(3), Some(5))
);
let bold = projection.requests[3].update_text_style.as_ref().unwrap();
assert_eq!(bold.text_style.as_ref().and_then(|t| t.bold), Some(true));
let bold_range = bold.range.as_ref().unwrap();
assert_eq!(
(bold_range.start_index, bold_range.end_index),
(Some(3), Some(5))
);
let link = projection.requests[4].update_text_style.as_ref().unwrap();
assert_eq!(
link.text_style
.as_ref()
.and_then(|t| t.link.as_ref())
.and_then(|l| l.url.as_deref()),
Some("https://x")
);
let link_range = link.range.as_ref().unwrap();
assert_eq!(
(link_range.start_index, link_range.end_index),
(Some(6), Some(7))
);
}
#[test]
fn inline_code_is_monospace() {
let projection = project(&doc(vec![paragraph(vec![Inline::InlineCode(
"x".to_owned(),
)])]));
let style = projection.requests[2].update_text_style.as_ref().unwrap();
assert_eq!(
style
.text_style
.as_ref()
.and_then(|t| t.weighted_font_family.as_ref())
.and_then(|f| f.font_family.as_deref()),
Some("Courier New")
);
}
#[test]
fn unordered_list_inserts_joined_text_and_bullets() {
let list = Block::List {
list_type: ListType::Unordered,
items: vec![
ListItem {
content: vec![paragraph(vec![plain("A")])],
checkbox: Checkbox::NoCheckbox,
},
ListItem {
content: vec![paragraph(vec![plain("B")])],
checkbox: Checkbox::NoCheckbox,
},
],
};
let projection = project(&doc(vec![list]));
assert_eq!(
projection.requests[0]
.insert_text
.as_ref()
.unwrap()
.text
.as_deref(),
Some("A\nB\n")
);
let bullets = projection.requests[1]
.create_paragraph_bullets
.as_ref()
.unwrap();
assert_eq!(
bullets.bullet_preset.as_deref(),
Some("BULLET_DISC_CIRCLE_SQUARE")
);
}
#[test]
fn ordered_list_uses_numbered_preset() {
let list = Block::List {
list_type: ListType::Ordered(1),
items: vec![ListItem {
content: vec![paragraph(vec![plain("only")])],
checkbox: Checkbox::NoCheckbox,
}],
};
let projection = project(&doc(vec![list]));
assert_eq!(
projection.requests[1]
.create_paragraph_bullets
.as_ref()
.unwrap()
.bullet_preset
.as_deref(),
Some("NUMBERED_DECIMAL_ALPHA_ROMAN")
);
}
#[test]
fn checkbox_list_marks_state_and_omits_bullets() {
let list = Block::List {
list_type: ListType::Unordered,
items: vec![
ListItem {
content: vec![paragraph(vec![plain("done")])],
checkbox: Checkbox::Checked,
},
ListItem {
content: vec![paragraph(vec![plain("todo")])],
checkbox: Checkbox::Unchecked,
},
],
};
let projection = project(&doc(vec![list]));
assert_eq!(
projection.requests[0]
.insert_text
.as_ref()
.unwrap()
.text
.as_deref(),
Some("\u{2611} done\n\u{2610} todo\n")
);
assert!(
projection
.requests
.iter()
.all(|r| r.create_paragraph_bullets.is_none())
);
}
#[test]
fn table_inserts_skeleton_then_cells_descending() {
let cell = |text: &str| TableCell {
inlines: vec![plain(text)],
};
let table = Block::Table {
rows: vec![vec![cell("A1"), cell("B1")], vec![cell("A2"), cell("B2")]],
};
let projection = project(&doc(vec![
table,
Block::Paragraph {
inlines: vec![plain("after")],
},
]));
let insert_table = projection.requests[0].insert_table.as_ref().unwrap();
assert_eq!(
(insert_table.rows, insert_table.columns),
(Some(2), Some(2))
);
assert_eq!(
insert_table.location.as_ref().and_then(|l| l.index),
Some(1)
);
let cells = &projection.requests[1..5];
let cell_indices: Vec<(Option<i32>, Option<&str>)> = cells
.iter()
.map(|r| {
let insert = r.insert_text.as_ref().unwrap();
(
insert.location.as_ref().and_then(|l| l.index),
insert.text.as_deref(),
)
})
.collect();
assert_eq!(
cell_indices,
vec![
(Some(12), Some("B2")),
(Some(10), Some("A2")),
(Some(7), Some("B1")),
(Some(5), Some("A1")),
]
);
let after = projection.requests[5].insert_text.as_ref().unwrap();
assert_eq!(after.text.as_deref(), Some("after\n"));
assert_eq!(after.location.as_ref().and_then(|l| l.index), Some(22));
let position = projection.positions.get("doc/table-1").unwrap();
assert_eq!((position.index, position.kind), (1, ElementKind::Table));
}
#[test]
fn src_block_is_monospace() {
let projection = project(&doc(vec![Block::SrcBlock {
language: "rust".to_owned(),
content: "fn main() {}".to_owned(),
}]));
assert_eq!(
projection.requests[0]
.insert_text
.as_ref()
.unwrap()
.text
.as_deref(),
Some("fn main() {}\n")
);
assert_eq!(
projection.requests[2]
.update_text_style
.as_ref()
.and_then(|s| s.text_style.as_ref())
.and_then(|t| t.weighted_font_family.as_ref())
.and_then(|f| f.font_family.as_deref()),
Some("Courier New")
);
}
#[test]
fn horizontal_rule_degrades_to_glyphs() {
let projection = project(&doc(vec![Block::HorizontalRule]));
let text = projection.requests[0]
.insert_text
.as_ref()
.unwrap()
.text
.as_deref();
assert!(text.is_some_and(|t| t.starts_with('\u{2500}')));
assert!(projection.positions.contains_key("doc/hr-1"));
}
#[test]
fn quote_paragraphs_are_indented() {
let quote = Block::QuoteBlock {
children: vec![paragraph(vec![plain("quoted")])],
};
let projection = project(&doc(vec![quote]));
let style = projection.requests[1]
.update_paragraph_style
.as_ref()
.unwrap();
assert!(
style
.paragraph_style
.as_ref()
.and_then(|p| p.indent_start.as_ref())
.is_some()
);
assert!(projection.positions.contains_key("doc/quote-1"));
}
#[test]
fn position_map_uses_custom_id_and_hierarchical_ids() {
let body = heading(
1,
"Intro",
vec![
custom_id_drawer("sec-intro"),
paragraph(vec![plain("first")]),
paragraph(vec![plain("second")]),
],
);
let projection = project(&doc(vec![body]));
let head = projection.positions.get("sec-intro").expect("heading id");
assert_eq!((head.index, head.kind), (1, ElementKind::Heading));
assert!(projection.positions.contains_key("sec-intro/paragraph-1"));
assert!(projection.positions.contains_key("sec-intro/paragraph-2"));
let p1 = projection.positions["sec-intro/paragraph-1"].index;
let p2 = projection.positions["sec-intro/paragraph-2"].index;
assert!(head.index < p1 && p1 < p2);
}
#[test]
fn metadata_blocks_produce_no_requests() {
let blocks = vec![
Block::BlankLine,
Block::Comment {
text: "hidden".to_owned(),
},
Block::Keyword {
name: "TITLE".to_owned(),
value: " Doc".to_owned(),
},
];
let projection = project(&doc(blocks));
assert!(projection.requests.is_empty());
assert!(projection.positions.is_empty());
}
#[test]
fn empty_table_emits_no_requests() {
let projection = project(&doc(vec![Block::Table { rows: vec![] }]));
assert!(projection.requests.is_empty());
}
#[test]
fn paragraph_soft_line_breaks_become_spaces() {
let para = paragraph(vec![
plain("first line"),
Inline::LineBreak,
plain("second line"),
]);
let projection = project(&doc(vec![para]));
let insert = projection.requests[0].insert_text.as_ref().unwrap();
assert_eq!(insert.text.as_deref(), Some("first line second line\n"));
}
#[test]
fn soft_break_after_trailing_space_does_not_double() {
let para = paragraph(vec![plain("first "), Inline::LineBreak, plain("second")]);
let projection = project(&doc(vec![para]));
let insert = projection.requests[0].insert_text.as_ref().unwrap();
assert_eq!(insert.text.as_deref(), Some("first second\n"));
}
#[test]
fn list_item_soft_line_break_stays_one_bullet() {
let list = Block::List {
list_type: ListType::Unordered,
items: vec![ListItem {
content: vec![paragraph(vec![
plain("wrapped"),
Inline::LineBreak,
plain("item"),
])],
checkbox: Checkbox::NoCheckbox,
}],
};
let projection = project(&doc(vec![list]));
let insert = projection.requests[0].insert_text.as_ref().unwrap();
assert_eq!(insert.text.as_deref(), Some("wrapped item\n"));
}
#[test]
fn table_drops_rule_row() {
let cell = |text: &str| TableCell {
inlines: vec![plain(text)],
};
let table = Block::Table {
rows: vec![
vec![cell("A1"), cell("B1")],
vec![cell("---+---")],
vec![cell("A2"), cell("B2")],
],
};
let projection = project(&doc(vec![
table,
Block::Paragraph {
inlines: vec![plain("after")],
},
]));
let insert_table = projection.requests[0].insert_table.as_ref().unwrap();
assert_eq!(
(insert_table.rows, insert_table.columns),
(Some(2), Some(2))
);
assert!(projection.requests.iter().all(|r| {
r.insert_text
.as_ref()
.and_then(|i| i.text.as_deref())
.is_none_or(|t| !t.contains('+'))
}));
let after = projection
.requests
.iter()
.filter_map(|r| r.insert_text.as_ref())
.find(|i| i.text.as_deref() == Some("after\n"))
.unwrap();
assert_eq!(after.location.as_ref().and_then(|l| l.index), Some(22));
}
#[test]
fn table_of_only_rule_row_emits_no_requests() {
let table = Block::Table {
rows: vec![vec![TableCell {
inlines: vec![plain("---+---")],
}]],
};
let projection = project(&doc(vec![table]));
assert!(projection.requests.is_empty());
}
#[test]
fn text_style_request_sets_strikethrough() {
let request = text_style_request(1, 3, &Style::Strikethrough);
assert_eq!(
request
.update_text_style
.and_then(|s| s.text_style)
.and_then(|t| t.strikethrough),
Some(true)
);
}
}