inkmark 0.1.0
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 +7 -0
- data/CHANGELOG.md +3 -0
- data/Cargo.lock +940 -0
- data/Cargo.toml +27 -0
- data/LICENSE.txt +21 -0
- data/NOTICE +16 -0
- data/README.md +1166 -0
- data/ext/inkmark/Cargo.toml +31 -0
- data/ext/inkmark/build.rs +5 -0
- data/ext/inkmark/extconf.rb +6 -0
- data/ext/inkmark/src/autolink.rs +167 -0
- data/ext/inkmark/src/chunks_by_heading.rs +325 -0
- data/ext/inkmark/src/chunks_by_size.rs +302 -0
- data/ext/inkmark/src/document.rs +411 -0
- data/ext/inkmark/src/emoji.rs +197 -0
- data/ext/inkmark/src/handler.rs +758 -0
- data/ext/inkmark/src/heading.rs +262 -0
- data/ext/inkmark/src/highlight.rs +202 -0
- data/ext/inkmark/src/image.rs +284 -0
- data/ext/inkmark/src/lib.rs +54 -0
- data/ext/inkmark/src/link.rs +291 -0
- data/ext/inkmark/src/options.rs +231 -0
- data/ext/inkmark/src/plain_text.rs +445 -0
- data/ext/inkmark/src/scheme_filter.rs +319 -0
- data/ext/inkmark/src/stats.rs +453 -0
- data/ext/inkmark/src/tag_filter.rs +226 -0
- data/ext/inkmark/src/toc.rs +221 -0
- data/ext/inkmark/src/truncate.rs +267 -0
- data/ext/inkmark/src/url_match.rs +178 -0
- data/lib/inkmark/event.rb +342 -0
- data/lib/inkmark/native.rb +8 -0
- data/lib/inkmark/options.rb +698 -0
- data/lib/inkmark/toc.rb +40 -0
- data/lib/inkmark/version.rb +6 -0
- data/lib/inkmark.rb +711 -0
- data/sig/inkmark.rbs +219 -0
- metadata +208 -0
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
[package]
|
|
2
|
+
name = "inkmark"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
edition = "2021"
|
|
5
|
+
authors = ["Yaroslav Markin <yaroslav@markin.net>"]
|
|
6
|
+
license = "MIT"
|
|
7
|
+
publish = false
|
|
8
|
+
|
|
9
|
+
[lib]
|
|
10
|
+
crate-type = ["cdylib"]
|
|
11
|
+
|
|
12
|
+
[dependencies]
|
|
13
|
+
magnus = { version = "0.8.2" }
|
|
14
|
+
rb-sys = { version = "0.9.126", features = ["stable-api-compiled-fallback"] }
|
|
15
|
+
pulldown-cmark = { version = "0.13", default-features = false, features = ["html", "simd"] }
|
|
16
|
+
pulldown-cmark-escape = "0.11"
|
|
17
|
+
deunicode = "1.6"
|
|
18
|
+
emojis = "0.8"
|
|
19
|
+
whatlang = "0.18"
|
|
20
|
+
unicode-segmentation = "1.12"
|
|
21
|
+
syntect = { version = "5.2", default-features = false, features = ["parsing", "html", "default-themes", "default-syntaxes", "regex-fancy"] }
|
|
22
|
+
linkify = "0.11"
|
|
23
|
+
globset = "0.4"
|
|
24
|
+
url = "2"
|
|
25
|
+
pulldown-cmark-to-cmark = "22"
|
|
26
|
+
|
|
27
|
+
[build-dependencies]
|
|
28
|
+
rb-sys-env = "0.2.2"
|
|
29
|
+
|
|
30
|
+
[dev-dependencies]
|
|
31
|
+
rb-sys-test-helpers = { version = "0.2.2" }
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
//! Auto-linking filter for bare URLs and email addresses.
|
|
2
|
+
//!
|
|
3
|
+
//! When enabled, scans `Event::Text` payloads for bare URLs and emails
|
|
4
|
+
//! using the `linkify` crate, and splits them into alternating
|
|
5
|
+
//! `Event::Text` / `Event::Start(Link)` + `Event::Text` + `Event::End(Link)`
|
|
6
|
+
//! sequences. Text inside code blocks and existing links is not touched.
|
|
7
|
+
|
|
8
|
+
use linkify::{LinkFinder, LinkKind};
|
|
9
|
+
use pulldown_cmark::{CowStr, Event, LinkType, Tag, TagEnd};
|
|
10
|
+
|
|
11
|
+
/// Scan text events for bare URLs/emails and wrap them in link events.
|
|
12
|
+
/// Tracks link and code-block depth so we don't autolink inside existing
|
|
13
|
+
/// links or code blocks.
|
|
14
|
+
pub fn autolink(events: Vec<Event<'_>>) -> Vec<Event<'_>> {
|
|
15
|
+
let finder = LinkFinder::new();
|
|
16
|
+
let mut out: Vec<Event<'_>> = Vec::with_capacity(events.len());
|
|
17
|
+
let mut link_depth: usize = 0;
|
|
18
|
+
let mut code_depth: usize = 0;
|
|
19
|
+
|
|
20
|
+
for event in events {
|
|
21
|
+
match &event {
|
|
22
|
+
Event::Start(Tag::Link { .. }) => link_depth += 1,
|
|
23
|
+
Event::End(TagEnd::Link) => link_depth = link_depth.saturating_sub(1),
|
|
24
|
+
Event::Start(Tag::CodeBlock(_)) => code_depth += 1,
|
|
25
|
+
Event::End(TagEnd::CodeBlock) => code_depth = code_depth.saturating_sub(1),
|
|
26
|
+
_ => {}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Only process Text events outside links and code blocks.
|
|
30
|
+
let dominated = link_depth > 0 || code_depth > 0;
|
|
31
|
+
let is_text = matches!(&event, Event::Text(_));
|
|
32
|
+
|
|
33
|
+
if !is_text || dominated {
|
|
34
|
+
out.push(event);
|
|
35
|
+
continue;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Extract the text and scan for links.
|
|
39
|
+
if let Event::Text(text) = event {
|
|
40
|
+
let spans: Vec<_> = finder.spans(&text).collect();
|
|
41
|
+
|
|
42
|
+
// Fast path: no links found—push original event unchanged.
|
|
43
|
+
if spans.iter().all(|s| s.kind().is_none()) {
|
|
44
|
+
out.push(Event::Text(text));
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Split the text into alternating plain / link spans.
|
|
49
|
+
for span in spans {
|
|
50
|
+
let fragment = &text[span.start()..span.end()];
|
|
51
|
+
match span.kind() {
|
|
52
|
+
Some(LinkKind::Url) => {
|
|
53
|
+
let url = CowStr::Boxed(fragment.to_string().into_boxed_str());
|
|
54
|
+
let display = CowStr::Boxed(fragment.to_string().into_boxed_str());
|
|
55
|
+
out.push(Event::Start(Tag::Link {
|
|
56
|
+
link_type: LinkType::Autolink,
|
|
57
|
+
dest_url: url,
|
|
58
|
+
title: CowStr::Borrowed(""),
|
|
59
|
+
id: CowStr::Borrowed(""),
|
|
60
|
+
}));
|
|
61
|
+
out.push(Event::Text(display));
|
|
62
|
+
out.push(Event::End(TagEnd::Link));
|
|
63
|
+
}
|
|
64
|
+
Some(LinkKind::Email) => {
|
|
65
|
+
// pulldown-cmark's HTML writer adds "mailto:" for
|
|
66
|
+
// LinkType::Email, so we pass just the address.
|
|
67
|
+
let addr = CowStr::Boxed(fragment.to_string().into_boxed_str());
|
|
68
|
+
let display = CowStr::Boxed(fragment.to_string().into_boxed_str());
|
|
69
|
+
out.push(Event::Start(Tag::Link {
|
|
70
|
+
link_type: LinkType::Email,
|
|
71
|
+
dest_url: addr,
|
|
72
|
+
title: CowStr::Borrowed(""),
|
|
73
|
+
id: CowStr::Borrowed(""),
|
|
74
|
+
}));
|
|
75
|
+
out.push(Event::Text(display));
|
|
76
|
+
out.push(Event::End(TagEnd::Link));
|
|
77
|
+
}
|
|
78
|
+
Some(_) | None => {
|
|
79
|
+
// Plain text segment—no link.
|
|
80
|
+
if !fragment.is_empty() {
|
|
81
|
+
out.push(Event::Text(CowStr::Boxed(
|
|
82
|
+
fragment.to_string().into_boxed_str(),
|
|
83
|
+
)));
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
out
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
#[cfg(test)]
|
|
95
|
+
mod tests {
|
|
96
|
+
use super::autolink;
|
|
97
|
+
use pulldown_cmark::{CowStr, Event, LinkType, Tag, TagEnd};
|
|
98
|
+
|
|
99
|
+
#[test]
|
|
100
|
+
fn bare_url_becomes_link() {
|
|
101
|
+
let events = vec![Event::Text(CowStr::Borrowed(
|
|
102
|
+
"Visit https://example.net today",
|
|
103
|
+
))];
|
|
104
|
+
let out = autolink(events);
|
|
105
|
+
// Should produce: Text("Visit ") + Start(Link) + Text("https://example.com") + End(Link) + Text(" today")
|
|
106
|
+
assert!(out.len() >= 5, "expected split events, got {}", out.len());
|
|
107
|
+
let has_link = out
|
|
108
|
+
.iter()
|
|
109
|
+
.any(|e| matches!(e, Event::Start(Tag::Link { .. })));
|
|
110
|
+
assert!(has_link, "no link event found");
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
#[test]
|
|
114
|
+
fn email_becomes_email_link() {
|
|
115
|
+
let events = vec![Event::Text(CowStr::Borrowed("Contact user@example.com"))];
|
|
116
|
+
let out = autolink(events);
|
|
117
|
+
// pulldown-cmark's HTML writer adds "mailto:" for LinkType::Email,
|
|
118
|
+
// so we only store the bare address in dest_url.
|
|
119
|
+
let has_email = out.iter().any(|e| match e {
|
|
120
|
+
Event::Start(Tag::Link {
|
|
121
|
+
link_type: LinkType::Email,
|
|
122
|
+
dest_url,
|
|
123
|
+
..
|
|
124
|
+
}) => dest_url.as_ref() == "user@example.com",
|
|
125
|
+
_ => false,
|
|
126
|
+
});
|
|
127
|
+
assert!(has_email, "no email link found in {out:?}");
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
#[test]
|
|
131
|
+
fn text_without_urls_unchanged() {
|
|
132
|
+
let events = vec![Event::Text(CowStr::Borrowed("just plain text"))];
|
|
133
|
+
let out = autolink(events);
|
|
134
|
+
assert_eq!(out.len(), 1);
|
|
135
|
+
assert!(matches!(&out[0], Event::Text(t) if t.as_ref() == "just plain text"));
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
#[test]
|
|
139
|
+
fn skips_inside_existing_links() {
|
|
140
|
+
let events = vec![
|
|
141
|
+
Event::Start(Tag::Link {
|
|
142
|
+
link_type: LinkType::Inline,
|
|
143
|
+
dest_url: CowStr::Borrowed("https://example.net"),
|
|
144
|
+
title: CowStr::Borrowed(""),
|
|
145
|
+
id: CowStr::Borrowed(""),
|
|
146
|
+
}),
|
|
147
|
+
Event::Text(CowStr::Borrowed("https://example.net")),
|
|
148
|
+
Event::End(TagEnd::Link),
|
|
149
|
+
];
|
|
150
|
+
let out = autolink(events);
|
|
151
|
+
// Should be unchanged—3 events, no extra links added.
|
|
152
|
+
assert_eq!(out.len(), 3);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
#[test]
|
|
156
|
+
fn skips_inside_code_blocks() {
|
|
157
|
+
let events = vec![
|
|
158
|
+
Event::Start(Tag::CodeBlock(pulldown_cmark::CodeBlockKind::Fenced(
|
|
159
|
+
CowStr::Borrowed(""),
|
|
160
|
+
))),
|
|
161
|
+
Event::Text(CowStr::Borrowed("https://example.net")),
|
|
162
|
+
Event::End(TagEnd::CodeBlock),
|
|
163
|
+
];
|
|
164
|
+
let out = autolink(events);
|
|
165
|
+
assert_eq!(out.len(), 3);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
@@ -0,0 +1,325 @@
|
|
|
1
|
+
//! Heading-based section extraction for LLM / RAG pipelines.
|
|
2
|
+
//!
|
|
3
|
+
//! Splits a document into hierarchical sections by heading.
|
|
4
|
+
//! Each section's `content` is filter-applied Markdown: emoji
|
|
5
|
+
//! expanded, URLs autolinked, host/scheme allowlists applied,
|
|
6
|
+
//! then serialized back through `pulldown-cmark-to-cmark`.
|
|
7
|
+
//!
|
|
8
|
+
//! Designed as a first-stage chunking primitive for
|
|
9
|
+
//! `chunk → embed → retrieve` pipelines: feed a document in, get an
|
|
10
|
+
//! ordered array of heading-led sections out. The Ruby side wraps this
|
|
11
|
+
//! with an optional `heading:` filter (String or Regexp).
|
|
12
|
+
//!
|
|
13
|
+
//! Heading Start/End pairs survive the filter pipeline intact —
|
|
14
|
+
//! emoji/autolink rewrites happen *inside* a heading's text events,
|
|
15
|
+
//! but the bracketing tags stay in place—so post-filter heading-
|
|
16
|
+
//! position scanning is coherent.
|
|
17
|
+
|
|
18
|
+
use magnus::{Error, RArray, RHash, Ruby};
|
|
19
|
+
use pulldown_cmark::{Event, HeadingLevel, Parser, Tag, TagEnd};
|
|
20
|
+
use unicode_segmentation::UnicodeSegmentation;
|
|
21
|
+
|
|
22
|
+
use crate::document::apply_filters;
|
|
23
|
+
use crate::heading::{self, SlugDeduplicator};
|
|
24
|
+
use crate::options::build_options;
|
|
25
|
+
use crate::toc;
|
|
26
|
+
use crate::truncate::{self, TruncateParams};
|
|
27
|
+
|
|
28
|
+
pub fn native_chunks_by_heading(
|
|
29
|
+
ruby: &Ruby,
|
|
30
|
+
source: String,
|
|
31
|
+
opts_hash: RHash,
|
|
32
|
+
) -> Result<RArray, Error> {
|
|
33
|
+
// The Ruby side merges the optional `truncate:` kwarg into the
|
|
34
|
+
// opts hash under the `:truncate` key before calling us. We pull
|
|
35
|
+
// it out here; the rest of `build_options` ignores it.
|
|
36
|
+
let truncate_params: Option<TruncateParams> = {
|
|
37
|
+
let nested: Option<RHash> = opts_hash.lookup(ruby.to_symbol("truncate"))?;
|
|
38
|
+
match nested {
|
|
39
|
+
Some(h) => Some(truncate::parse_params(ruby, h)?),
|
|
40
|
+
None => None,
|
|
41
|
+
}
|
|
42
|
+
};
|
|
43
|
+
let (cm_opts, flags) = build_options(ruby, opts_hash)?;
|
|
44
|
+
|
|
45
|
+
// Parse + run the full filter pipeline, same as `to_markdown`.
|
|
46
|
+
let events: Vec<Event> = Parser::new_ext(&source, cm_opts).collect();
|
|
47
|
+
let events = apply_filters(events, &flags);
|
|
48
|
+
|
|
49
|
+
let boundaries = find_heading_boundaries(&events);
|
|
50
|
+
let result = ruby.ary_new();
|
|
51
|
+
|
|
52
|
+
// Preamble: events before the first heading, or the whole doc
|
|
53
|
+
// when there are no headings at all. Emitted as an entry with
|
|
54
|
+
// `heading: nil, level: 0, id: nil`. Skipped entirely when there
|
|
55
|
+
// is no non-empty content before the first heading.
|
|
56
|
+
let with_counts = flags.statistics;
|
|
57
|
+
let preamble_end = boundaries.first().map(|b| b.start).unwrap_or(events.len());
|
|
58
|
+
if preamble_end > 0 {
|
|
59
|
+
let preamble_events = &events[0..preamble_end];
|
|
60
|
+
if !is_empty_content(preamble_events) {
|
|
61
|
+
result.push(build_preamble_hash(
|
|
62
|
+
ruby,
|
|
63
|
+
preamble_events,
|
|
64
|
+
with_counts,
|
|
65
|
+
truncate_params.as_ref(),
|
|
66
|
+
)?)?;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// One entry per heading. Section end is the position of the
|
|
71
|
+
// next heading with level <= current level (or end of events).
|
|
72
|
+
//
|
|
73
|
+
// `ancestors` tracks the heading stack so we can attach a
|
|
74
|
+
// breadcrumb (root → immediate-parent) to each section. At
|
|
75
|
+
// each boundary we pop any ancestors whose level is >= the
|
|
76
|
+
// current boundary's level (those aren't parents), then record
|
|
77
|
+
// the remaining stack as this section's breadcrumb, then push
|
|
78
|
+
// the current heading for its own subsections' use.
|
|
79
|
+
let mut dedup = SlugDeduplicator::new();
|
|
80
|
+
let mut ancestors: Vec<(u8, String)> = Vec::new();
|
|
81
|
+
for (i, boundary) in boundaries.iter().enumerate() {
|
|
82
|
+
let section_end = find_section_end(&boundaries, i, events.len());
|
|
83
|
+
let level = toc::level_to_u8(boundary.level);
|
|
84
|
+
while ancestors.last().is_some_and(|(l, _)| *l >= level) {
|
|
85
|
+
ancestors.pop();
|
|
86
|
+
}
|
|
87
|
+
let breadcrumb: Vec<&str> = ancestors.iter().map(|(_, t)| t.as_str()).collect();
|
|
88
|
+
let heading_text = collect_inline_text(&events[(boundary.start + 1)..boundary.end]);
|
|
89
|
+
let hash = build_section_hash(
|
|
90
|
+
ruby,
|
|
91
|
+
&events,
|
|
92
|
+
boundary,
|
|
93
|
+
section_end,
|
|
94
|
+
&heading_text,
|
|
95
|
+
&breadcrumb,
|
|
96
|
+
&mut dedup,
|
|
97
|
+
with_counts,
|
|
98
|
+
truncate_params.as_ref(),
|
|
99
|
+
)?;
|
|
100
|
+
result.push(hash)?;
|
|
101
|
+
ancestors.push((level, heading_text));
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
Ok(result)
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/// A discovered `Start(Heading) / End(Heading)` pair in the filtered
|
|
108
|
+
/// event stream.
|
|
109
|
+
struct HeadingBoundary {
|
|
110
|
+
start: usize,
|
|
111
|
+
end: usize,
|
|
112
|
+
level: HeadingLevel,
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
fn find_heading_boundaries(events: &[Event<'_>]) -> Vec<HeadingBoundary> {
|
|
116
|
+
let mut boundaries = Vec::new();
|
|
117
|
+
let mut i = 0;
|
|
118
|
+
while i < events.len() {
|
|
119
|
+
if let Event::Start(Tag::Heading { level, .. }) = &events[i] {
|
|
120
|
+
let lvl = *level;
|
|
121
|
+
// CommonMark disallows headings inside headings, but carry a
|
|
122
|
+
// depth counter so we stay correct if pulldown-cmark ever
|
|
123
|
+
// permits them.
|
|
124
|
+
let mut depth = 1usize;
|
|
125
|
+
let mut j = i + 1;
|
|
126
|
+
while j < events.len() {
|
|
127
|
+
match &events[j] {
|
|
128
|
+
Event::Start(Tag::Heading { .. }) => depth += 1,
|
|
129
|
+
Event::End(TagEnd::Heading(_)) => {
|
|
130
|
+
depth -= 1;
|
|
131
|
+
if depth == 0 {
|
|
132
|
+
break;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
_ => {}
|
|
136
|
+
}
|
|
137
|
+
j += 1;
|
|
138
|
+
}
|
|
139
|
+
boundaries.push(HeadingBoundary {
|
|
140
|
+
start: i,
|
|
141
|
+
end: j,
|
|
142
|
+
level: lvl,
|
|
143
|
+
});
|
|
144
|
+
i = j + 1;
|
|
145
|
+
} else {
|
|
146
|
+
i += 1;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
boundaries
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/// Section i ends at the first subsequent heading with level <=
|
|
153
|
+
/// current. Headings with a strictly greater level are subsections:
|
|
154
|
+
/// they belong to the current section's content too.
|
|
155
|
+
fn find_section_end(boundaries: &[HeadingBoundary], i: usize, events_len: usize) -> usize {
|
|
156
|
+
let current = toc::level_to_u8(boundaries[i].level);
|
|
157
|
+
boundaries[(i + 1)..]
|
|
158
|
+
.iter()
|
|
159
|
+
.find(|b| toc::level_to_u8(b.level) <= current)
|
|
160
|
+
.map(|b| b.start)
|
|
161
|
+
.unwrap_or(events_len)
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
fn build_preamble_hash(
|
|
165
|
+
ruby: &Ruby,
|
|
166
|
+
events: &[Event<'_>],
|
|
167
|
+
with_counts: bool,
|
|
168
|
+
truncate_params: Option<&TruncateParams>,
|
|
169
|
+
) -> Result<RHash, Error> {
|
|
170
|
+
let hash = ruby.hash_new();
|
|
171
|
+
hash.aset(ruby.to_symbol("heading"), ())?;
|
|
172
|
+
hash.aset(ruby.to_symbol("level"), 0u8)?;
|
|
173
|
+
hash.aset(ruby.to_symbol("id"), ())?;
|
|
174
|
+
// Preamble has no ancestors; empty array keeps the shape uniform
|
|
175
|
+
// with proper sections so callers can treat every entry alike.
|
|
176
|
+
hash.aset(ruby.to_symbol("breadcrumb"), ruby.ary_new_capa(0))?;
|
|
177
|
+
|
|
178
|
+
let content = match truncate_params {
|
|
179
|
+
Some(params) => truncate::truncate_events(events, params),
|
|
180
|
+
None => render_markdown(events),
|
|
181
|
+
};
|
|
182
|
+
if with_counts {
|
|
183
|
+
let (chars, words) = count_post_truncate(events, truncate_params, &content);
|
|
184
|
+
hash.aset(ruby.to_symbol("character_count"), chars)?;
|
|
185
|
+
hash.aset(ruby.to_symbol("word_count"), words)?;
|
|
186
|
+
}
|
|
187
|
+
hash.aset(ruby.to_symbol("content"), content)?;
|
|
188
|
+
Ok(hash)
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
fn build_section_hash(
|
|
192
|
+
ruby: &Ruby,
|
|
193
|
+
events: &[Event<'_>],
|
|
194
|
+
boundary: &HeadingBoundary,
|
|
195
|
+
section_end: usize,
|
|
196
|
+
heading_text: &str,
|
|
197
|
+
breadcrumb: &[&str],
|
|
198
|
+
dedup: &mut SlugDeduplicator,
|
|
199
|
+
with_counts: bool,
|
|
200
|
+
truncate_params: Option<&TruncateParams>,
|
|
201
|
+
) -> Result<RHash, Error> {
|
|
202
|
+
// Slug is the deduplicated slugify of the (filter-applied) heading
|
|
203
|
+
// text, matching the ids `heading_ids` / `toc` would emit for the
|
|
204
|
+
// same document.
|
|
205
|
+
let base = heading::slugify(heading_text);
|
|
206
|
+
let id = if base.is_empty() {
|
|
207
|
+
String::new()
|
|
208
|
+
} else {
|
|
209
|
+
dedup.deduplicate(base)
|
|
210
|
+
};
|
|
211
|
+
|
|
212
|
+
// Content = events after End(Heading) up to the next section or
|
|
213
|
+
// end of document. Re-serialized through cmark_write.
|
|
214
|
+
let content_events = &events[(boundary.end + 1)..section_end];
|
|
215
|
+
|
|
216
|
+
let hash = ruby.hash_new();
|
|
217
|
+
hash.aset(ruby.to_symbol("heading"), heading_text)?;
|
|
218
|
+
hash.aset(ruby.to_symbol("level"), toc::level_to_u8(boundary.level))?;
|
|
219
|
+
if id.is_empty() {
|
|
220
|
+
hash.aset(ruby.to_symbol("id"), ())?;
|
|
221
|
+
} else {
|
|
222
|
+
hash.aset(ruby.to_symbol("id"), id)?;
|
|
223
|
+
}
|
|
224
|
+
let breadcrumb_arr = ruby.ary_new_capa(breadcrumb.len());
|
|
225
|
+
for text in breadcrumb {
|
|
226
|
+
breadcrumb_arr.push(*text)?;
|
|
227
|
+
}
|
|
228
|
+
hash.aset(ruby.to_symbol("breadcrumb"), breadcrumb_arr)?;
|
|
229
|
+
|
|
230
|
+
let content = match truncate_params {
|
|
231
|
+
Some(params) => truncate::truncate_events(content_events, params),
|
|
232
|
+
None => render_markdown(content_events),
|
|
233
|
+
};
|
|
234
|
+
if with_counts {
|
|
235
|
+
let (chars, words) = count_post_truncate(content_events, truncate_params, &content);
|
|
236
|
+
hash.aset(ruby.to_symbol("character_count"), chars)?;
|
|
237
|
+
hash.aset(ruby.to_symbol("word_count"), words)?;
|
|
238
|
+
}
|
|
239
|
+
hash.aset(ruby.to_symbol("content"), content)?;
|
|
240
|
+
Ok(hash)
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/// Return (character_count, word_count) for a section.
|
|
244
|
+
///
|
|
245
|
+
/// Without truncation: counts come from the original event stream's
|
|
246
|
+
/// Text/Code events.
|
|
247
|
+
///
|
|
248
|
+
/// With truncation: reparse the truncated Markdown and count from its
|
|
249
|
+
/// events.
|
|
250
|
+
fn count_post_truncate(
|
|
251
|
+
original_events: &[Event<'_>],
|
|
252
|
+
truncate_params: Option<&TruncateParams>,
|
|
253
|
+
truncated_content: &str,
|
|
254
|
+
) -> (usize, usize) {
|
|
255
|
+
if truncate_params.is_none() {
|
|
256
|
+
return count_text(original_events);
|
|
257
|
+
}
|
|
258
|
+
let events: Vec<Event> = Parser::new_ext(
|
|
259
|
+
truncated_content,
|
|
260
|
+
pulldown_cmark::Options::ENABLE_GFM
|
|
261
|
+
| pulldown_cmark::Options::ENABLE_TABLES
|
|
262
|
+
| pulldown_cmark::Options::ENABLE_STRIKETHROUGH
|
|
263
|
+
| pulldown_cmark::Options::ENABLE_TASKLISTS
|
|
264
|
+
| pulldown_cmark::Options::ENABLE_FOOTNOTES,
|
|
265
|
+
)
|
|
266
|
+
.collect();
|
|
267
|
+
count_text(&events)
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/// Count characters (after trimming) and unicode words in a section's
|
|
271
|
+
/// Text/Code event stream. Code-block contents are included: matches
|
|
272
|
+
/// document-level {stats::collect} semantics and reflects what an
|
|
273
|
+
/// embedding model would actually consume.
|
|
274
|
+
fn count_text(events: &[Event<'_>]) -> (usize, usize) {
|
|
275
|
+
let mut buf = String::new();
|
|
276
|
+
for event in events {
|
|
277
|
+
match event {
|
|
278
|
+
Event::Text(t) | Event::Code(t) => {
|
|
279
|
+
buf.push_str(t);
|
|
280
|
+
buf.push(' ');
|
|
281
|
+
}
|
|
282
|
+
Event::SoftBreak | Event::HardBreak => buf.push(' '),
|
|
283
|
+
_ => {}
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
let chars = buf.trim().chars().count();
|
|
287
|
+
let words = buf.unicode_words().count();
|
|
288
|
+
(chars, words)
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
fn render_markdown(events: &[Event<'_>]) -> String {
|
|
292
|
+
let mut buf = String::new();
|
|
293
|
+
pulldown_cmark_to_cmark::cmark(events.iter().cloned(), &mut buf)
|
|
294
|
+
.expect("markdown serialization failed");
|
|
295
|
+
buf
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
fn collect_inline_text(events: &[Event<'_>]) -> String {
|
|
299
|
+
let mut out = String::new();
|
|
300
|
+
for event in events {
|
|
301
|
+
match event {
|
|
302
|
+
Event::Text(t) | Event::Code(t) => out.push_str(t),
|
|
303
|
+
_ => {}
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
out
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/// A preamble (or whole-doc when there are no headings) is meaningful
|
|
310
|
+
/// only when it contains actual content: text, code, or raw HTML.
|
|
311
|
+
/// Whitespace-only event streams produce an empty preamble entry that
|
|
312
|
+
/// would just add noise.
|
|
313
|
+
fn is_empty_content(events: &[Event<'_>]) -> bool {
|
|
314
|
+
!events.iter().any(|e| {
|
|
315
|
+
matches!(
|
|
316
|
+
e,
|
|
317
|
+
Event::Text(_)
|
|
318
|
+
| Event::Code(_)
|
|
319
|
+
| Event::Html(_)
|
|
320
|
+
| Event::InlineHtml(_)
|
|
321
|
+
| Event::InlineMath(_)
|
|
322
|
+
| Event::DisplayMath(_)
|
|
323
|
+
)
|
|
324
|
+
})
|
|
325
|
+
}
|