inkmark 0.2.2 → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +14 -0
- data/Cargo.lock +4 -3
- data/README.md +20 -0
- data/ext/inkmark/Cargo.toml +2 -2
- data/ext/inkmark/src/chunks_by_heading.rs +91 -31
- data/ext/inkmark/src/chunks_by_size.rs +33 -18
- data/ext/inkmark/src/document.rs +22 -20
- data/ext/inkmark/src/image.rs +57 -30
- data/ext/inkmark/src/lib.rs +1 -0
- data/ext/inkmark/src/link.rs +10 -9
- data/ext/inkmark/src/markdown.rs +436 -0
- data/ext/inkmark/src/scheme_filter.rs +31 -6
- data/ext/inkmark/src/truncate.rs +40 -23
- data/lib/inkmark/version.rb +1 -1
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 4a3feea6c75cbbc48daa868ce5aa140f1929df7b6b109ee83e6ed67c4b290351
|
|
4
|
+
data.tar.gz: ddddb3918c21fe25cf33f2b58370475c9e5f4a1a0b2963b32de904d9d096cf1f
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 0a02d6c1b1f9067787cf880fbf044b59bc05220bc075d99332af7e0cac416f2bdcf2545b935d7d309db45411d67f01ab4f0614ed52f0ddc1f28bb261ddff892c
|
|
7
|
+
data.tar.gz: 242ecf84608b2de0fdd37e883506d3fbd0ec7cb1dc5184bbb707e1648a99e0ca4d3834ee48d74b21d69d90cf5c3922fb1c36d48754b521c858c20e00c3af9e17
|
data/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,17 @@
|
|
|
1
|
+
## [0.3.1] - 2026-10-24
|
|
2
|
+
|
|
3
|
+
- Re-release of 0.3.0, which was yanked.
|
|
4
|
+
- Breaking: Markdown output (`to_markdown`, `chunks_by_heading`, `chunks_by_size`, `truncate_markdown`) uses pulldown-cmark-to-cmark 23; escaping and formatting differ from 0.2.
|
|
5
|
+
- Fix raw HTML leaking into Markdown output with `raw_html: false` when a line held more than one tag.
|
|
6
|
+
- Fix `chunks_by_size` and `truncate_markdown` (`at: :block`) joining blocks without a blank line, which merged adjacent paragraphs.
|
|
7
|
+
- Fix Markdown output crashing on an image nested in another image's alt text with `images: { lazy: true }`, `allowed_hosts`, or `allowed_schemes`.
|
|
8
|
+
- `chunks_by_heading` starts sections only at top-level headings; a heading inside a block quote or list item no longer splits it.
|
|
9
|
+
- Raise `Inkmark::Error` on Markdown serialization failures instead of crashing the process.
|
|
10
|
+
|
|
11
|
+
## [0.3.0] - 2026-10-24 [YANKED]
|
|
12
|
+
|
|
13
|
+
- Yanked: a RubyGems issue interrupted publishing, so only one platform gem was released. Its changes shipped in 0.3.1.
|
|
14
|
+
|
|
1
15
|
## [0.2.2] - 2026-10-24
|
|
2
16
|
|
|
3
17
|
- Updated dependencies, including pulldown-cmark 0.13.5 (upstream security fixes) and magnus 0.9.3.
|
data/Cargo.lock
CHANGED
|
@@ -333,7 +333,7 @@ dependencies = [
|
|
|
333
333
|
|
|
334
334
|
[[package]]
|
|
335
335
|
name = "inkmark"
|
|
336
|
-
version = "0.
|
|
336
|
+
version = "0.3.1"
|
|
337
337
|
dependencies = [
|
|
338
338
|
"deunicode",
|
|
339
339
|
"emojis",
|
|
@@ -527,11 +527,12 @@ checksum = "007d8adb5ddab6f8e3f491ac63566a7d5002cc7ed73901f72057943fa71ae1ae"
|
|
|
527
527
|
|
|
528
528
|
[[package]]
|
|
529
529
|
name = "pulldown-cmark-to-cmark"
|
|
530
|
-
version = "
|
|
530
|
+
version = "23.0.0"
|
|
531
531
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
532
|
-
checksum = "
|
|
532
|
+
checksum = "e9e14cd4ec725b2227ba76d4772169af814438bcf48f6d9b806d1308f40a7413"
|
|
533
533
|
dependencies = [
|
|
534
534
|
"pulldown-cmark",
|
|
535
|
+
"unicase",
|
|
535
536
|
]
|
|
536
537
|
|
|
537
538
|
[[package]]
|
data/README.md
CHANGED
|
@@ -206,6 +206,9 @@ Inkmark.to_html("<script>alert(1)</script>")
|
|
|
206
206
|
# => "<p><script>alert(1)</script></p>\n"
|
|
207
207
|
```
|
|
208
208
|
|
|
209
|
+
Markdown output escapes it too; see
|
|
210
|
+
[Raw HTML in the source](#raw-html-in-the-source).
|
|
211
|
+
|
|
209
212
|
Enable pass-through with `raw_html: true`; _only do this for trusted
|
|
210
213
|
input_:
|
|
211
214
|
|
|
@@ -403,6 +406,12 @@ nested `###` subsections, which also appear as their own entries. Content
|
|
|
403
406
|
before the first heading (if any) becomes a preamble entry with
|
|
404
407
|
`heading: nil` and `level: 0`.
|
|
405
408
|
|
|
409
|
+
Only **top-level** headings start a section. A heading inside a block
|
|
410
|
+
quote or list item (such as a `## Tip` in a `> [!NOTE]` callout) stays in
|
|
411
|
+
the content of the section around it, so the quote or list is never split
|
|
412
|
+
across sections. It still counts toward slug deduplication, so every
|
|
413
|
+
section's `:id` matches the anchor `headings: { ids: true }` generates.
|
|
414
|
+
|
|
406
415
|
`:breadcrumb` carries the ancestor heading texts from root to immediate
|
|
407
416
|
parent. Root-level sections and the preamble have an empty array. Skipped
|
|
408
417
|
levels are omitted, so an `###` directly under an `#` has `breadcrumb:
|
|
@@ -740,6 +749,17 @@ Inkmark.to_markdown(source, options: {
|
|
|
740
749
|
})
|
|
741
750
|
```
|
|
742
751
|
|
|
752
|
+
### Raw HTML in the source
|
|
753
|
+
|
|
754
|
+
With `raw_html: false` (the default), raw HTML from the source comes out of
|
|
755
|
+
`to_markdown`, `chunks_by_heading`, `chunks_by_size`, and `truncate_markdown`
|
|
756
|
+
escaped, so the Markdown stays inert even if a later stage renders it with raw
|
|
757
|
+
HTML allowed. HTML produced by the HTML-emitting filters above is the
|
|
758
|
+
exception: it is embedded as described.
|
|
759
|
+
|
|
760
|
+
If the filtered document cannot be written back as Markdown, these methods
|
|
761
|
+
raise `Inkmark::Error`.
|
|
762
|
+
|
|
743
763
|
## Event handlers
|
|
744
764
|
|
|
745
765
|
Register handlers with `#on` to inspect or transform document elements as they
|
data/ext/inkmark/Cargo.toml
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[package]
|
|
2
2
|
name = "inkmark"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.3.1"
|
|
4
4
|
edition = "2021"
|
|
5
5
|
authors = ["Yaroslav Markin <yaroslav@markin.net>"]
|
|
6
6
|
license = "MIT"
|
|
@@ -22,7 +22,7 @@ syntect = { version = "5.2", default-features = false, features = ["parsing", "h
|
|
|
22
22
|
linkify = "0.11"
|
|
23
23
|
globset = "0.4"
|
|
24
24
|
url = "2"
|
|
25
|
-
pulldown-cmark-to-cmark = "
|
|
25
|
+
pulldown-cmark-to-cmark = "23"
|
|
26
26
|
|
|
27
27
|
[build-dependencies]
|
|
28
28
|
rb-sys-env = "0.2.2"
|
|
@@ -14,6 +14,12 @@
|
|
|
14
14
|
//! emoji/autolink rewrites happen *inside* a heading's text events,
|
|
15
15
|
//! but the bracketing tags stay in place—so post-filter heading-
|
|
16
16
|
//! position scanning is coherent.
|
|
17
|
+
//!
|
|
18
|
+
//! Only top-level headings start a section. A heading inside a block
|
|
19
|
+
//! quote, list item, or other container stays in the content of the
|
|
20
|
+
//! section around it, so a container is never split across sections.
|
|
21
|
+
//! Nested headings still consume slugs, keeping section ids aligned
|
|
22
|
+
//! with the ids `heading_ids` / `toc` emit.
|
|
17
23
|
|
|
18
24
|
use magnus::{Error, RArray, RHash, Ruby};
|
|
19
25
|
use pulldown_cmark::{Event, HeadingLevel, Parser, Tag, TagEnd};
|
|
@@ -21,6 +27,7 @@ use unicode_segmentation::UnicodeSegmentation;
|
|
|
21
27
|
|
|
22
28
|
use crate::document::{apply_filters, content_events};
|
|
23
29
|
use crate::heading::{self, SlugDeduplicator};
|
|
30
|
+
use crate::markdown;
|
|
24
31
|
use crate::options::build_options;
|
|
25
32
|
use crate::toc;
|
|
26
33
|
use crate::truncate::{self, TruncateParams};
|
|
@@ -57,8 +64,13 @@ pub fn native_chunks_by_heading(
|
|
|
57
64
|
let content_opts = ContentOptions {
|
|
58
65
|
with_counts: flags.statistics,
|
|
59
66
|
truncate_params: truncate_params.as_ref(),
|
|
67
|
+
md_opts: markdown::serializer_options(cm_opts, &flags),
|
|
60
68
|
};
|
|
61
|
-
let preamble_end = boundaries
|
|
69
|
+
let preamble_end = boundaries
|
|
70
|
+
.iter()
|
|
71
|
+
.find(|b| b.top_level)
|
|
72
|
+
.map(|b| b.start)
|
|
73
|
+
.unwrap_or(events.len());
|
|
62
74
|
if preamble_end > 0 {
|
|
63
75
|
let preamble_events = &events[0..preamble_end];
|
|
64
76
|
if !is_empty_content(preamble_events) {
|
|
@@ -78,23 +90,29 @@ pub fn native_chunks_by_heading(
|
|
|
78
90
|
let mut dedup = SlugDeduplicator::new();
|
|
79
91
|
let mut ancestors: Vec<(u8, String)> = Vec::new();
|
|
80
92
|
for (i, boundary) in boundaries.iter().enumerate() {
|
|
93
|
+
let heading_text = collect_inline_text(&events[(boundary.start + 1)..boundary.end]);
|
|
94
|
+
let id = heading_id(&mut dedup, &heading_text);
|
|
95
|
+
if !boundary.top_level {
|
|
96
|
+
// Stays in the surrounding section's content; only its slug
|
|
97
|
+
// counts, so later ids match `heading_ids`.
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
81
100
|
let section_end = find_section_end(&boundaries, i, events.len());
|
|
82
101
|
let level = toc::level_to_u8(boundary.level);
|
|
83
102
|
while ancestors.last().is_some_and(|(l, _)| *l >= level) {
|
|
84
103
|
ancestors.pop();
|
|
85
104
|
}
|
|
86
105
|
let breadcrumb: Vec<&str> = ancestors.iter().map(|(_, t)| t.as_str()).collect();
|
|
87
|
-
let heading_text = collect_inline_text(&events[(boundary.start + 1)..boundary.end]);
|
|
88
106
|
// Content = events after End(Heading) up to the next section or
|
|
89
|
-
// end of document. Re-serialized through
|
|
107
|
+
// end of document. Re-serialized through `markdown::render`.
|
|
90
108
|
let content_events = &events[(boundary.end + 1)..section_end];
|
|
91
109
|
let hash = build_section_hash(
|
|
92
110
|
ruby,
|
|
93
111
|
boundary.level,
|
|
94
112
|
&heading_text,
|
|
113
|
+
&id,
|
|
95
114
|
&breadcrumb,
|
|
96
115
|
content_events,
|
|
97
|
-
&mut dedup,
|
|
98
116
|
&content_opts,
|
|
99
117
|
)?;
|
|
100
118
|
result.push(hash)?;
|
|
@@ -110,6 +128,9 @@ struct HeadingBoundary {
|
|
|
110
128
|
start: usize,
|
|
111
129
|
end: usize,
|
|
112
130
|
level: HeadingLevel,
|
|
131
|
+
/// Not inside a block quote, list item, or other container. Only
|
|
132
|
+
/// top-level headings start sections.
|
|
133
|
+
top_level: bool,
|
|
113
134
|
}
|
|
114
135
|
|
|
115
136
|
/// How each entry's `content` is rendered: optionally truncated, and
|
|
@@ -117,14 +138,20 @@ struct HeadingBoundary {
|
|
|
117
138
|
struct ContentOptions<'a> {
|
|
118
139
|
with_counts: bool,
|
|
119
140
|
truncate_params: Option<&'a TruncateParams>,
|
|
141
|
+
/// Serializer options, from `markdown::serializer_options`.
|
|
142
|
+
md_opts: pulldown_cmark::Options,
|
|
120
143
|
}
|
|
121
144
|
|
|
122
145
|
fn find_heading_boundaries(events: &[Event<'_>]) -> Vec<HeadingBoundary> {
|
|
123
146
|
let mut boundaries = Vec::new();
|
|
147
|
+
// Containers open around the cursor. Headings are leaf blocks, so
|
|
148
|
+
// none open at a Start(Heading) means it is not inside anything.
|
|
149
|
+
let mut open_containers = 0usize;
|
|
124
150
|
let mut i = 0;
|
|
125
151
|
while i < events.len() {
|
|
126
152
|
if let Event::Start(Tag::Heading { level, .. }) = &events[i] {
|
|
127
153
|
let lvl = *level;
|
|
154
|
+
let top_level = open_containers == 0;
|
|
128
155
|
// CommonMark disallows headings inside headings, but carry a
|
|
129
156
|
// depth counter so we stay correct if pulldown-cmark ever
|
|
130
157
|
// permits them.
|
|
@@ -147,23 +174,29 @@ fn find_heading_boundaries(events: &[Event<'_>]) -> Vec<HeadingBoundary> {
|
|
|
147
174
|
start: i,
|
|
148
175
|
end: j,
|
|
149
176
|
level: lvl,
|
|
177
|
+
top_level,
|
|
150
178
|
});
|
|
151
179
|
i = j + 1;
|
|
152
180
|
} else {
|
|
181
|
+
match &events[i] {
|
|
182
|
+
Event::Start(_) => open_containers += 1,
|
|
183
|
+
Event::End(_) => open_containers = open_containers.saturating_sub(1),
|
|
184
|
+
_ => {}
|
|
185
|
+
}
|
|
153
186
|
i += 1;
|
|
154
187
|
}
|
|
155
188
|
}
|
|
156
189
|
boundaries
|
|
157
190
|
}
|
|
158
191
|
|
|
159
|
-
/// Section i ends at the first subsequent heading with level
|
|
160
|
-
/// current. Headings with a strictly greater level are subsections:
|
|
192
|
+
/// Section i ends at the first subsequent top-level heading with level
|
|
193
|
+
/// <= current. Headings with a strictly greater level are subsections:
|
|
161
194
|
/// they belong to the current section's content too.
|
|
162
195
|
fn find_section_end(boundaries: &[HeadingBoundary], i: usize, events_len: usize) -> usize {
|
|
163
196
|
let current = toc::level_to_u8(boundaries[i].level);
|
|
164
197
|
boundaries[(i + 1)..]
|
|
165
198
|
.iter()
|
|
166
|
-
.find(|b| toc::level_to_u8(b.level) <= current)
|
|
199
|
+
.find(|b| b.top_level && toc::level_to_u8(b.level) <= current)
|
|
167
200
|
.map(|b| b.start)
|
|
168
201
|
.unwrap_or(events_len)
|
|
169
202
|
}
|
|
@@ -181,10 +214,7 @@ fn build_preamble_hash(
|
|
|
181
214
|
// with proper sections so callers can treat every entry alike.
|
|
182
215
|
hash.aset(ruby.to_symbol("breadcrumb"), ruby.ary_new_capa(0))?;
|
|
183
216
|
|
|
184
|
-
let content =
|
|
185
|
-
Some(params) => truncate::truncate_events(events, params),
|
|
186
|
-
None => render_markdown(events),
|
|
187
|
-
};
|
|
217
|
+
let content = render_content(ruby, events, content_opts)?;
|
|
188
218
|
if content_opts.with_counts {
|
|
189
219
|
let (chars, words) = count_post_truncate(events, content_opts.truncate_params, &content);
|
|
190
220
|
hash.aset(ruby.to_symbol("character_count"), chars)?;
|
|
@@ -198,21 +228,11 @@ fn build_section_hash(
|
|
|
198
228
|
ruby: &Ruby,
|
|
199
229
|
level: HeadingLevel,
|
|
200
230
|
heading_text: &str,
|
|
231
|
+
id: &str,
|
|
201
232
|
breadcrumb: &[&str],
|
|
202
233
|
content_events: &[Event<'_>],
|
|
203
|
-
dedup: &mut SlugDeduplicator,
|
|
204
234
|
content_opts: &ContentOptions<'_>,
|
|
205
235
|
) -> Result<RHash, Error> {
|
|
206
|
-
// Slug is the deduplicated slugify of the (filter-applied) heading
|
|
207
|
-
// text, matching the ids `heading_ids` / `toc` would emit for the
|
|
208
|
-
// same document.
|
|
209
|
-
let base = heading::slugify(heading_text);
|
|
210
|
-
let id = if base.is_empty() {
|
|
211
|
-
String::new()
|
|
212
|
-
} else {
|
|
213
|
-
dedup.deduplicate(base)
|
|
214
|
-
};
|
|
215
|
-
|
|
216
236
|
let hash = ruby.hash_new();
|
|
217
237
|
hash.aset(ruby.to_symbol("heading"), heading_text)?;
|
|
218
238
|
hash.aset(ruby.to_symbol("level"), toc::level_to_u8(level))?;
|
|
@@ -227,10 +247,7 @@ fn build_section_hash(
|
|
|
227
247
|
}
|
|
228
248
|
hash.aset(ruby.to_symbol("breadcrumb"), breadcrumb_arr)?;
|
|
229
249
|
|
|
230
|
-
let content =
|
|
231
|
-
Some(params) => truncate::truncate_events(content_events, params),
|
|
232
|
-
None => render_markdown(content_events),
|
|
233
|
-
};
|
|
250
|
+
let content = render_content(ruby, content_events, content_opts)?;
|
|
234
251
|
if content_opts.with_counts {
|
|
235
252
|
let (chars, words) =
|
|
236
253
|
count_post_truncate(content_events, content_opts.truncate_params, &content);
|
|
@@ -289,11 +306,24 @@ fn count_text(events: &[Event<'_>]) -> (usize, usize) {
|
|
|
289
306
|
(chars, words)
|
|
290
307
|
}
|
|
291
308
|
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
309
|
+
/// Deduplicated slug of the (filter-applied) heading text, matching the
|
|
310
|
+
/// ids `heading_ids` / `toc` would emit for the same document. Empty when
|
|
311
|
+
/// the text has nothing to slugify.
|
|
312
|
+
fn heading_id(dedup: &mut SlugDeduplicator, heading_text: &str) -> String {
|
|
313
|
+
dedup.deduplicate(heading::slugify(heading_text))
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/// Serialize a section's events to Markdown, truncated when requested.
|
|
317
|
+
fn render_content(
|
|
318
|
+
ruby: &Ruby,
|
|
319
|
+
events: &[Event<'_>],
|
|
320
|
+
content_opts: &ContentOptions<'_>,
|
|
321
|
+
) -> Result<String, Error> {
|
|
322
|
+
match content_opts.truncate_params {
|
|
323
|
+
Some(params) => truncate::truncate_events(events, params, content_opts.md_opts),
|
|
324
|
+
None => markdown::render(events, content_opts.md_opts),
|
|
325
|
+
}
|
|
326
|
+
.map_err(|e| markdown::to_ruby_error(ruby, e))
|
|
297
327
|
}
|
|
298
328
|
|
|
299
329
|
fn collect_inline_text(events: &[Event<'_>]) -> String {
|
|
@@ -324,3 +354,33 @@ fn is_empty_content(events: &[Event<'_>]) -> bool {
|
|
|
324
354
|
)
|
|
325
355
|
})
|
|
326
356
|
}
|
|
357
|
+
|
|
358
|
+
#[cfg(test)]
|
|
359
|
+
mod tests {
|
|
360
|
+
use super::*;
|
|
361
|
+
use pulldown_cmark::Options;
|
|
362
|
+
|
|
363
|
+
fn events(source: &str) -> Vec<Event<'_>> {
|
|
364
|
+
Parser::new_ext(source, Options::empty()).collect()
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
#[test]
|
|
368
|
+
fn headings_inside_containers_are_not_top_level() {
|
|
369
|
+
let ev = events("# Top\n\n> ## Quoted\n\n- # Listed\n\n## Sub\n");
|
|
370
|
+
let top_level: Vec<bool> = find_heading_boundaries(&ev)
|
|
371
|
+
.iter()
|
|
372
|
+
.map(|b| b.top_level)
|
|
373
|
+
.collect();
|
|
374
|
+
assert_eq!(top_level, vec![true, false, false, true]);
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
#[test]
|
|
378
|
+
fn section_spanning_a_nested_heading_stays_balanced() {
|
|
379
|
+
let ev = events("# Top\n\n> ## Inner\n> text\n\nafter\n");
|
|
380
|
+
let boundaries = find_heading_boundaries(&ev);
|
|
381
|
+
let end = find_section_end(&boundaries, 0, ev.len());
|
|
382
|
+
let content = &ev[(boundaries[0].end + 1)..end];
|
|
383
|
+
let md = markdown::render(content, Options::empty()).expect("balanced slice");
|
|
384
|
+
assert!(md.contains("Inner") && md.contains("after"), "{md:?}");
|
|
385
|
+
}
|
|
386
|
+
}
|
|
@@ -22,6 +22,7 @@ use pulldown_cmark::Event;
|
|
|
22
22
|
use unicode_segmentation::UnicodeSegmentation;
|
|
23
23
|
|
|
24
24
|
use crate::document::{apply_filters, content_events};
|
|
25
|
+
use crate::markdown::{self, SerializeError};
|
|
25
26
|
use crate::options::build_options;
|
|
26
27
|
|
|
27
28
|
#[derive(Clone, Copy, PartialEq, Eq)]
|
|
@@ -48,10 +49,12 @@ pub fn native_chunks_by_size(
|
|
|
48
49
|
let events: Vec<Event> = content_events(&source, cm_opts).collect();
|
|
49
50
|
let events = apply_filters(events, &flags);
|
|
50
51
|
|
|
52
|
+
let md_opts = markdown::serializer_options(cm_opts, &flags);
|
|
51
53
|
let windows = match params.at {
|
|
52
|
-
BoundaryAt::Block => chunk_blocks(&events, ¶ms),
|
|
53
|
-
BoundaryAt::Word => chunk_words(&events, ¶ms),
|
|
54
|
-
}
|
|
54
|
+
BoundaryAt::Block => chunk_blocks(&events, ¶ms, md_opts),
|
|
55
|
+
BoundaryAt::Word => chunk_words(&events, ¶ms, md_opts),
|
|
56
|
+
}
|
|
57
|
+
.map_err(|e| markdown::to_ruby_error(ruby, e))?;
|
|
55
58
|
|
|
56
59
|
build_result(ruby, &windows, flags.statistics)
|
|
57
60
|
}
|
|
@@ -79,20 +82,30 @@ fn parse_params(ruby: &Ruby, hash: &RHash) -> Result<WindowParams, Error> {
|
|
|
79
82
|
})
|
|
80
83
|
}
|
|
81
84
|
|
|
82
|
-
fn chunk_blocks(
|
|
85
|
+
fn chunk_blocks(
|
|
86
|
+
events: &[Event<'_>],
|
|
87
|
+
params: &WindowParams,
|
|
88
|
+
md_opts: pulldown_cmark::Options,
|
|
89
|
+
) -> Result<Vec<String>, SerializeError> {
|
|
83
90
|
let mut out: Vec<String> = Vec::new();
|
|
84
91
|
let mut current = String::new();
|
|
85
92
|
let mut current_chars: usize = 0;
|
|
86
93
|
let mut current_words: usize = 0;
|
|
87
94
|
|
|
88
95
|
for (start, end) in top_level_blocks(events) {
|
|
89
|
-
let block =
|
|
96
|
+
let block = markdown::render_block(&events[start..=end], md_opts)?;
|
|
90
97
|
let block_chars = block.chars().count();
|
|
91
98
|
let block_words = block.unicode_words().count();
|
|
99
|
+
// The blank line rejoining blocks counts toward the char budget.
|
|
100
|
+
let separator_chars = if current.is_empty() {
|
|
101
|
+
0
|
|
102
|
+
} else {
|
|
103
|
+
markdown::BLOCK_SEPARATOR.len()
|
|
104
|
+
};
|
|
92
105
|
|
|
93
106
|
let would_exceed_chars = params
|
|
94
107
|
.chars
|
|
95
|
-
.map(|b| current_chars + block_chars > b)
|
|
108
|
+
.map(|b| current_chars + separator_chars + block_chars > b)
|
|
96
109
|
.unwrap_or(false);
|
|
97
110
|
let would_exceed_words = params
|
|
98
111
|
.words
|
|
@@ -110,6 +123,11 @@ fn chunk_blocks(events: &[Event<'_>], params: &WindowParams) -> Vec<String> {
|
|
|
110
123
|
out.push(finished);
|
|
111
124
|
}
|
|
112
125
|
|
|
126
|
+
// Re-checked: a flush leaves `current` holding the overlap seed.
|
|
127
|
+
if !current.is_empty() {
|
|
128
|
+
current.push_str(markdown::BLOCK_SEPARATOR);
|
|
129
|
+
current_chars += markdown::BLOCK_SEPARATOR.len();
|
|
130
|
+
}
|
|
113
131
|
current.push_str(&block);
|
|
114
132
|
current_chars += block_chars;
|
|
115
133
|
current_words += block_words;
|
|
@@ -118,7 +136,7 @@ fn chunk_blocks(events: &[Event<'_>], params: &WindowParams) -> Vec<String> {
|
|
|
118
136
|
if !current.is_empty() {
|
|
119
137
|
out.push(current);
|
|
120
138
|
}
|
|
121
|
-
out
|
|
139
|
+
Ok(out)
|
|
122
140
|
}
|
|
123
141
|
|
|
124
142
|
/// Return the trailing `overlap` characters of `s`, aligned to a
|
|
@@ -167,13 +185,6 @@ fn top_level_blocks(events: &[Event<'_>]) -> Vec<(usize, usize)> {
|
|
|
167
185
|
blocks
|
|
168
186
|
}
|
|
169
187
|
|
|
170
|
-
fn render_markdown(events: &[Event<'_>]) -> String {
|
|
171
|
-
let mut buf = String::new();
|
|
172
|
-
pulldown_cmark_to_cmark::cmark(events.iter().cloned(), &mut buf)
|
|
173
|
-
.expect("markdown serialization failed");
|
|
174
|
-
buf
|
|
175
|
-
}
|
|
176
|
-
|
|
177
188
|
// Serialize the full filtered Markdown into one string, then walk
|
|
178
189
|
// word boundaries. Each window is a byte-aligned slice of the
|
|
179
190
|
// serialized output ending at a word boundary that fits the budget.
|
|
@@ -181,10 +192,14 @@ fn render_markdown(events: &[Event<'_>]) -> String {
|
|
|
181
192
|
// `overlap` chars (to the next word boundary) before taking the
|
|
182
193
|
// next window.
|
|
183
194
|
|
|
184
|
-
fn chunk_words(
|
|
185
|
-
|
|
195
|
+
fn chunk_words(
|
|
196
|
+
events: &[Event<'_>],
|
|
197
|
+
params: &WindowParams,
|
|
198
|
+
md_opts: pulldown_cmark::Options,
|
|
199
|
+
) -> Result<Vec<String>, SerializeError> {
|
|
200
|
+
let rendered = markdown::render(events, md_opts)?;
|
|
186
201
|
if rendered.is_empty() {
|
|
187
|
-
return Vec::new();
|
|
202
|
+
return Ok(Vec::new());
|
|
188
203
|
}
|
|
189
204
|
|
|
190
205
|
let mut out: Vec<String> = Vec::new();
|
|
@@ -250,7 +265,7 @@ fn chunk_words(events: &[Event<'_>], params: &WindowParams) -> Vec<String> {
|
|
|
250
265
|
};
|
|
251
266
|
}
|
|
252
267
|
|
|
253
|
-
out
|
|
268
|
+
Ok(out)
|
|
254
269
|
}
|
|
255
270
|
|
|
256
271
|
/// Step the cursor back by roughly `overlap` characters, then land on
|
data/ext/inkmark/src/document.rs
CHANGED
|
@@ -7,6 +7,7 @@ use crate::heading;
|
|
|
7
7
|
use crate::highlight;
|
|
8
8
|
use crate::image;
|
|
9
9
|
use crate::link;
|
|
10
|
+
use crate::markdown::{self, SerializeError};
|
|
10
11
|
use crate::options::{build_options, Flags};
|
|
11
12
|
use crate::plain_text;
|
|
12
13
|
use crate::scheme_filter::SchemeFilter;
|
|
@@ -36,13 +37,14 @@ pub fn native_to_markdown(
|
|
|
36
37
|
source: String,
|
|
37
38
|
opts_hash: Option<RHash>,
|
|
38
39
|
) -> Result<String, Error> {
|
|
39
|
-
match opts_hash {
|
|
40
|
-
None =>
|
|
40
|
+
let rendered = match opts_hash {
|
|
41
|
+
None => markdown_defaults(&source),
|
|
41
42
|
Some(hash) => {
|
|
42
43
|
let (cm_opts, flags) = build_options(ruby, hash)?;
|
|
43
|
-
|
|
44
|
+
render_to_markdown(&source, cm_opts, flags)
|
|
44
45
|
}
|
|
45
|
-
}
|
|
46
|
+
};
|
|
47
|
+
rendered.map_err(|e| markdown::to_ruby_error(ruby, e))
|
|
46
48
|
}
|
|
47
49
|
|
|
48
50
|
pub fn native_to_plain_text(
|
|
@@ -71,12 +73,13 @@ fn render_defaults(source: &str) -> String {
|
|
|
71
73
|
}
|
|
72
74
|
|
|
73
75
|
/// Same as render_defaults but serializes to Markdown instead of HTML.
|
|
74
|
-
fn markdown_defaults(source: &str) -> String {
|
|
76
|
+
fn markdown_defaults(source: &str) -> Result<String, SerializeError> {
|
|
75
77
|
let mut buf = String::with_capacity(source.len());
|
|
76
|
-
let
|
|
78
|
+
let cm_opts = default_cm_opts();
|
|
79
|
+
let parser = Parser::new_ext(source, cm_opts);
|
|
77
80
|
let filtered = parser.map(suppress_raw_html);
|
|
78
|
-
|
|
79
|
-
buf
|
|
81
|
+
markdown::write(filtered, &mut buf, cm_opts)?;
|
|
82
|
+
Ok(buf)
|
|
80
83
|
}
|
|
81
84
|
|
|
82
85
|
/// Defaults-only plain-text fast path. Mirrors `markdown_defaults`:
|
|
@@ -255,22 +258,25 @@ fn render(source: &str, cm_opts: pulldown_cmark::Options, flags: Flags) -> Strin
|
|
|
255
258
|
buf
|
|
256
259
|
}
|
|
257
260
|
|
|
258
|
-
fn render_to_markdown(
|
|
259
|
-
|
|
260
|
-
|
|
261
|
+
fn render_to_markdown(
|
|
262
|
+
source: &str,
|
|
263
|
+
cm_opts: pulldown_cmark::Options,
|
|
264
|
+
flags: Flags,
|
|
265
|
+
) -> Result<String, SerializeError> {
|
|
261
266
|
// `content_events` strips frontmatter, so the cmark serializer never sees
|
|
262
267
|
// a metadata block to re-emit—on either the streaming or buffered path.
|
|
263
268
|
if !needs_buffer(&flags) {
|
|
264
|
-
|
|
269
|
+
let mut buf = String::with_capacity(source.len());
|
|
270
|
+
markdown::write(
|
|
265
271
|
content_events(source, cm_opts).map(stream_filter(&flags)),
|
|
266
272
|
&mut buf,
|
|
267
|
-
|
|
268
|
-
|
|
273
|
+
cm_opts,
|
|
274
|
+
)?;
|
|
275
|
+
return Ok(buf);
|
|
269
276
|
}
|
|
270
277
|
|
|
271
278
|
let events = apply_filters(content_events(source, cm_opts).collect(), &flags);
|
|
272
|
-
|
|
273
|
-
buf
|
|
279
|
+
markdown::render(&events, markdown::serializer_options(cm_opts, &flags))
|
|
274
280
|
}
|
|
275
281
|
|
|
276
282
|
fn render_to_plain_text(source: &str, cm_opts: pulldown_cmark::Options, flags: Flags) -> String {
|
|
@@ -447,7 +453,3 @@ pub fn apply_post_handler_filters<'a>(mut events: Vec<Event<'a>>, flags: &Flags)
|
|
|
447
453
|
|
|
448
454
|
events
|
|
449
455
|
}
|
|
450
|
-
|
|
451
|
-
fn cmark_write<'a, I: Iterator<Item = Event<'a>>>(events: I, buf: &mut String) {
|
|
452
|
-
pulldown_cmark_to_cmark::cmark(events, buf).expect("markdown serialization failed");
|
|
453
|
-
}
|