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.
@@ -35,8 +35,10 @@ pub struct SchemeFilter<'e, 's, I: Iterator<Item = Event<'e>>> {
35
35
  // disallows nested links so this is usually 1, but the counter keeps
36
36
  // us correct on malformed / extension-emitted streams.
37
37
  skipping_link_depth: usize,
38
- // State for in-progress image drop.
39
- skipping_image: bool,
38
+ // State for in-progress image drop, counted like links: an image can
39
+ // nest in another image's alt text (`![a ![b](x)](y)`). 0 = not
40
+ // skipping.
41
+ skipping_image_depth: usize,
40
42
  image_alt: String,
41
43
  }
42
44
 
@@ -51,7 +53,7 @@ impl<'e, 's, I: Iterator<Item = Event<'e>>> SchemeFilter<'e, 's, I> {
51
53
  link_allowed,
52
54
  image_allowed,
53
55
  skipping_link_depth: 0,
54
- skipping_image: false,
56
+ skipping_image_depth: 0,
55
57
  image_alt: String::new(),
56
58
  }
57
59
  }
@@ -63,10 +65,17 @@ impl<'e, 's, I: Iterator<Item = Event<'e>>> Iterator for SchemeFilter<'e, 's, I>
63
65
  fn next(&mut self) -> Option<Event<'e>> {
64
66
  loop {
65
67
  // Image skipping mode
66
- if self.skipping_image {
68
+ if self.skipping_image_depth > 0 {
67
69
  match self.inner.next()? {
70
+ Event::Start(Tag::Image { .. }) => {
71
+ self.skipping_image_depth += 1;
72
+ continue;
73
+ }
68
74
  Event::End(TagEnd::Image) => {
69
- self.skipping_image = false;
75
+ self.skipping_image_depth -= 1;
76
+ if self.skipping_image_depth > 0 {
77
+ continue;
78
+ }
70
79
  if !self.image_alt.is_empty() {
71
80
  let alt = std::mem::take(&mut self.image_alt);
72
81
  return Some(Event::Text(CowStr::Boxed(alt.into_boxed_str())));
@@ -117,7 +126,7 @@ impl<'e, 's, I: Iterator<Item = Event<'e>>> Iterator for SchemeFilter<'e, 's, I>
117
126
  .image_allowed
118
127
  .is_some_and(|s| !is_scheme_allowed(dest_url, s)) =>
119
128
  {
120
- self.skipping_image = true;
129
+ self.skipping_image_depth = 1;
121
130
  self.image_alt.clear();
122
131
  continue;
123
132
  }
@@ -162,6 +171,22 @@ mod tests {
162
171
  assert!(matches!(out[0], Event::Start(Tag::Link { .. })));
163
172
  }
164
173
 
174
+ #[test]
175
+ fn drops_blocked_image_with_an_image_nested_in_its_alt_text() {
176
+ let source = "![foo ![bar](http://x.com/1)](http://x.com/2)";
177
+ let events: Vec<Event> = pulldown_cmark::Parser::new(source).collect();
178
+ let allowed = schemes(&["https"]);
179
+ let out = run(events, None, Some(&allowed));
180
+ assert!(
181
+ !out.iter().any(|e| matches!(
182
+ e,
183
+ Event::Start(Tag::Image { .. }) | Event::End(TagEnd::Image)
184
+ )),
185
+ "stray image event: {out:?}"
186
+ );
187
+ assert!(out.contains(&Event::Text(CowStr::Borrowed("foo bar"))));
188
+ }
189
+
165
190
  #[test]
166
191
  fn drops_blocked_link_keeps_text() {
167
192
  let events = vec![
@@ -11,6 +11,7 @@ use pulldown_cmark::Event;
11
11
  use unicode_segmentation::UnicodeSegmentation;
12
12
 
13
13
  use crate::document::{apply_filters, content_events};
14
+ use crate::markdown::{self, SerializeError};
14
15
  use crate::options::{build_options, Flags};
15
16
 
16
17
  /// What kind of boundary to cut at.
@@ -43,10 +44,14 @@ pub fn truncate_source(
43
44
  cm_opts: pulldown_cmark::Options,
44
45
  flags: &Flags,
45
46
  params: &TruncateParams,
46
- ) -> String {
47
+ ) -> Result<String, SerializeError> {
47
48
  let events: Vec<Event> = content_events(source, cm_opts).collect();
48
49
  let events = apply_filters(events, flags);
49
- truncate_events(&events, params)
50
+ truncate_events(
51
+ &events,
52
+ params,
53
+ markdown::serializer_options(cm_opts, flags),
54
+ )
50
55
  }
51
56
 
52
57
  /// Ruby-facing entry point. Expects `params_hash` to contain
@@ -61,7 +66,7 @@ pub fn native_truncate_markdown(
61
66
  ) -> Result<String, Error> {
62
67
  let (cm_opts, flags) = build_options(ruby, opts_hash)?;
63
68
  let params = parse_params(ruby, params_hash)?;
64
- Ok(truncate_source(&source, cm_opts, &flags, &params))
69
+ truncate_source(&source, cm_opts, &flags, &params).map_err(|e| markdown::to_ruby_error(ruby, e))
65
70
  }
66
71
 
67
72
  pub fn parse_params(ruby: &Ruby, hash: RHash) -> Result<TruncateParams, Error> {
@@ -85,14 +90,22 @@ pub fn parse_params(ruby: &Ruby, hash: RHash) -> Result<TruncateParams, Error> {
85
90
  }
86
91
 
87
92
  /// Core truncation over a filter-applied event slice.
88
- pub fn truncate_events(events: &[Event<'_>], params: &TruncateParams) -> String {
93
+ pub fn truncate_events(
94
+ events: &[Event<'_>],
95
+ params: &TruncateParams,
96
+ md_opts: pulldown_cmark::Options,
97
+ ) -> Result<String, SerializeError> {
89
98
  match params.at {
90
- TruncateAt::Block => truncate_at_block(events, params),
91
- TruncateAt::Word => truncate_at_word(events, params),
99
+ TruncateAt::Block => truncate_at_block(events, params, md_opts),
100
+ TruncateAt::Word => truncate_at_word(events, params, md_opts),
92
101
  }
93
102
  }
94
103
 
95
- fn truncate_at_block(events: &[Event<'_>], params: &TruncateParams) -> String {
104
+ fn truncate_at_block(
105
+ events: &[Event<'_>],
106
+ params: &TruncateParams,
107
+ md_opts: pulldown_cmark::Options,
108
+ ) -> Result<String, SerializeError> {
96
109
  let marker_chars = marker_chars(&params.marker);
97
110
  let marker_words = marker_words(&params.marker);
98
111
  let char_budget = params.chars.map(|n| n.saturating_sub(marker_chars));
@@ -104,8 +117,14 @@ fn truncate_at_block(events: &[Event<'_>], params: &TruncateParams) -> String {
104
117
  let mut any_dropped = false;
105
118
 
106
119
  for (start, end) in top_level_blocks(events) {
107
- let block = render_markdown(&events[start..=end]);
108
- let block_chars = block.trim_end().chars().count();
120
+ let block = markdown::render_block(&events[start..=end], md_opts)?;
121
+ // The blank line rejoining blocks counts toward the char budget.
122
+ let separator = if kept.is_empty() {
123
+ ""
124
+ } else {
125
+ markdown::BLOCK_SEPARATOR
126
+ };
127
+ let block_chars = separator.len() + block.trim_end().chars().count();
109
128
  let block_words = block.unicode_words().count();
110
129
 
111
130
  let would_exceed_chars = char_budget
@@ -120,6 +139,7 @@ fn truncate_at_block(events: &[Event<'_>], params: &TruncateParams) -> String {
120
139
  break;
121
140
  }
122
141
 
142
+ kept.push_str(separator);
123
143
  kept.push_str(&block);
124
144
  used_chars += block_chars;
125
145
  used_words += block_words;
@@ -127,12 +147,12 @@ fn truncate_at_block(events: &[Event<'_>], params: &TruncateParams) -> String {
127
147
 
128
148
  // If nothing was dropped and source fit, return unchanged (no marker).
129
149
  if !any_dropped {
130
- return kept;
150
+ return Ok(kept);
131
151
  }
132
152
 
133
153
  // First block alone exceeded the budget; honest empty return
134
154
  if kept.is_empty() {
135
- return String::new();
155
+ return Ok(String::new());
136
156
  }
137
157
 
138
158
  // Trim trailing whitespace before appending the marker so the
@@ -145,7 +165,7 @@ fn truncate_at_block(events: &[Event<'_>], params: &TruncateParams) -> String {
145
165
  kept.push_str(marker);
146
166
  }
147
167
  kept.push('\n');
148
- kept
168
+ Ok(kept)
149
169
  }
150
170
 
151
171
  /// Return (start, end) event-index pairs for top-level blocks.
@@ -185,15 +205,12 @@ fn top_level_blocks(events: &[Event<'_>]) -> Vec<(usize, usize)> {
185
205
  blocks
186
206
  }
187
207
 
188
- fn render_markdown(events: &[Event<'_>]) -> String {
189
- let mut buf = String::new();
190
- pulldown_cmark_to_cmark::cmark(events.iter().cloned(), &mut buf)
191
- .expect("markdown serialization failed");
192
- buf
193
- }
194
-
195
- fn truncate_at_word(events: &[Event<'_>], params: &TruncateParams) -> String {
196
- let rendered = render_markdown(events);
208
+ fn truncate_at_word(
209
+ events: &[Event<'_>],
210
+ params: &TruncateParams,
211
+ md_opts: pulldown_cmark::Options,
212
+ ) -> Result<String, SerializeError> {
213
+ let rendered = markdown::render(events, md_opts)?;
197
214
  let marker_chars = marker_chars(&params.marker);
198
215
  let marker_words = marker_words(&params.marker);
199
216
 
@@ -211,7 +228,7 @@ fn truncate_at_word(events: &[Event<'_>], params: &TruncateParams) -> String {
211
228
 
212
229
  // Fits under both budgets: return unchanged, no marker.
213
230
  if chars_ok && words_ok {
214
- return rendered;
231
+ return Ok(rendered);
215
232
  }
216
233
 
217
234
  let char_budget = params.chars.map(|n| n.saturating_sub(marker_chars));
@@ -252,7 +269,7 @@ fn truncate_at_word(events: &[Event<'_>], params: &TruncateParams) -> String {
252
269
  if let Some(marker) = &params.marker {
253
270
  out.push_str(marker);
254
271
  }
255
- out
272
+ Ok(out)
256
273
  }
257
274
 
258
275
  fn marker_chars(marker: &Option<String>) -> usize {
@@ -2,5 +2,5 @@
2
2
 
3
3
  class Inkmark
4
4
  # Current gem version.
5
- VERSION = "0.2.2"
5
+ VERSION = "0.3.1"
6
6
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: inkmark
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.2
4
+ version: 0.3.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Yaroslav Markin
@@ -164,6 +164,7 @@ files:
164
164
  - ext/inkmark/src/image.rs
165
165
  - ext/inkmark/src/lib.rs
166
166
  - ext/inkmark/src/link.rs
167
+ - ext/inkmark/src/markdown.rs
167
168
  - ext/inkmark/src/options.rs
168
169
  - ext/inkmark/src/plain_text.rs
169
170
  - ext/inkmark/src/scheme_filter.rs