| 1 | //! Hyperlinks as OneNote 2010 edits them (`corpus/link-edit/native-typed`): the Link dialog |
| 2 | //! stores the address as typed in a hidden `HYPERLINK` field code before the label, a URL |
| 3 | //! typed and ended by a space or Enter becomes a link of its own text, typing after a label |
| 4 | //! is plain text, and Remove Link leaves the label as plain text. |
| 5 | |
| 6 | use super::format::restyle; |
| 7 | use super::*; |
| 8 | |
| 9 | /// A link in a paragraph, in UTF-16 offsets of its stored text. |
| 10 | #[derive(Clone, Debug, PartialEq, Eq)] |
| 11 | pub struct Link { |
| 12 | pub(crate) paragraph: usize, |
| 13 | /// The hidden field code naming the address, before the label; none where the label is |
| 14 | /// its own address. |
| 15 | pub(crate) code: Option<Range<u32>>, |
| 16 | pub label: Range<u32>, |
| 17 | pub(crate) target: String, |
| 18 | } |
| 19 | |
| 20 | impl Link { |
| 21 | fn range(&self) -> Range<u32> { |
| 22 | self.code |
| 23 | .as_ref() |
| 24 | .map_or(self.label.start, |code| code.start)..self.label.end |
| 25 | } |
| 26 | } |
| 27 | |
| 28 | /// The links in `text`, the text of paragraph `paragraph`. |
| 29 | pub(crate) fn links(text: &Paragraph, paragraph: usize) -> Vec<Link> { |
| 30 | let mut found: Vec<Link> = Vec::new(); |
| 31 | let mut code: Option<(Range<u32>, String)> = None; |
| 32 | let (mut byte, mut unit) = (0, 0); |
| 33 | for span in text.spans() { |
| 34 | let fragment = &text.text()[byte..span.end]; |
| 35 | let range = unit..unit + fragment.encode_utf16().count() as u32; |
| 36 | (byte, unit) = (span.end, range.end); |
| 37 | let format = &span.format; |
| 38 | if format.hyperlink != Some(true) { |
| 39 | code = None; |
| 40 | continue; |
| 41 | } |
| 42 | if format.hidden == Some(true) { |
| 43 | code = fragment |
| 44 | .strip_prefix("\u{fddf}HYPERLINK \"") |
| 45 | .and_then(|target| target.strip_suffix('"')) |
| 46 | .map(|target| (range, target.to_owned())); |
| 47 | continue; |
| 48 | } |
| 49 | let labelled = format.hyperlink_label == Some(true); |
| 50 | match (code.take(), found.last_mut()) { |
| 51 | (Some((code, target)), _) if code.end == range.start => found.push(Link { |
| 52 | paragraph, |
| 53 | code: Some(code), |
| 54 | label: range, |
| 55 | target, |
| 56 | }), |
| 57 | // A label runs on across format changes; a bare link is its own text. |
| 58 | (_, Some(last)) if last.label.end == range.start && last.code.is_some() == labelled => { |
| 59 | last.label.end = range.end; |
| 60 | } |
| 61 | _ => found.push(Link { |
| 62 | paragraph, |
| 63 | code: None, |
| 64 | label: range, |
| 65 | target: String::new(), |
| 66 | }), |
| 67 | } |
| 68 | } |
| 69 | for link in &mut found { |
| 70 | if link.code.is_none() { |
| 71 | let start = text.byte_offset(link.label.start).unwrap_or(0); |
| 72 | let end = text.byte_offset(link.label.end).unwrap_or(start); |
| 73 | link.target = text.text()[start..end].to_owned(); |
| 74 | } |
| 75 | } |
| 76 | found |
| 77 | } |
| 78 | |
| 79 | /// Where a typed URL lies in `word`, a run of text ended by a space or Enter, as OneNote |
| 80 | /// links it: from a scheme it knows (or `www.`, or a share's `\\`) preceded by punctuation |
| 81 | /// only, to before any trailing punctuation or unmatched closing bracket. |
| 82 | pub(crate) fn typed_url(word: &str) -> Option<Range<usize>> { |
| 83 | const SCHEMES: [&str; 9] = [ |
| 84 | "http://", "https://", "ftp://", "file://", "mailto:", "news:", "onenote:", "www.", "\\\\", |
| 85 | ]; |
| 86 | let lower = word.to_ascii_lowercase(); |
| 87 | let (start, scheme) = SCHEMES |
| 88 | .iter() |
| 89 | .filter_map(|scheme| Some((lower.find(scheme)?, scheme.len()))) |
| 90 | .min()?; |
| 91 | if word[..start].chars().any(char::is_alphanumeric) { |
| 92 | return None; |
| 93 | } |
| 94 | let mut end = word.len(); |
| 95 | while let Some(last) = word[start..end].chars().last() { |
| 96 | let count = |c: char| word[start..end].matches(c).count(); |
| 97 | let trailing = match last { |
| 98 | '.' | ',' | ';' | ':' | '!' | '?' | '\'' | '"' => true, |
| 99 | ')' => count('(') < count(')'), |
| 100 | ']' => count('[') < count(']'), |
| 101 | '}' => count('{') < count('}'), |
| 102 | _ => false, |
| 103 | }; |
| 104 | if !trailing { |
| 105 | break; |
| 106 | } |
| 107 | end -= last.len_utf8(); |
| 108 | } |
| 109 | (end > start + scheme).then_some(start..end) |
| 110 | } |
| 111 | |
| 112 | /// The URLs written as text in `text`, as byte ranges: OneNote links them when it opens a |
| 113 | /// page, so they show and open as links without being stored as ones. |
| 114 | pub fn shown_urls(text: &Paragraph) -> Vec<Range<usize>> { |
| 115 | let plain = |
| 116 | |format: &Format| ![format.hyperlink, format.hidden, format.math].contains(&Some(true)); |
| 117 | let mut urls = Vec::new(); |
| 118 | let mut start = 0; |
| 119 | for span in text.spans() { |
| 120 | if !plain(&span.format) { |
| 121 | start = span.end; |
| 122 | continue; |
| 123 | } |
| 124 | let run = &text.text()[start..span.end]; |
| 125 | let mut at = 0; |
| 126 | for word in run.split(char::is_whitespace) { |
| 127 | // Most words hold none of what a URL starts with. |
| 128 | let candidate = word.contains([':', '\\']) |
| 129 | || word |
| 130 | .as_bytes() |
| 131 | .windows(4) |
| 132 | .any(|w| w.eq_ignore_ascii_case(b"www.")); |
| 133 | if candidate && let Some(url) = typed_url(word) { |
| 134 | urls.push(start + at + url.start..start + at + url.end); |
| 135 | } |
| 136 | at += word.len() |
| 137 | + run[at + word.len()..] |
| 138 | .chars() |
| 139 | .next() |
| 140 | .map_or(0, char::len_utf8); |
| 141 | } |
| 142 | start = span.end; |
| 143 | } |
| 144 | urls |
| 145 | } |
| 146 | |
| 147 | /// The format of the character at UTF-16 `offset`. |
| 148 | pub(crate) fn format_after(text: &Paragraph, offset: u32) -> Option<&Format> { |
| 149 | let byte = text.byte_offset(offset).ok()?; |
| 150 | let length = text.text()[byte..].chars().next()?.len_utf16() as u32; |
| 151 | text.format_at(offset + length).ok() |
| 152 | } |
| 153 | |
| 154 | /// Text that follows a link rather than extending it. |
| 155 | pub(crate) fn unlinked(format: &mut Format) { |
| 156 | format.hidden = format.hidden.map(|_| false); |
| 157 | format.hyperlink = format.hyperlink.map(|_| false); |
| 158 | format.hyperlink_label = None; |
| 159 | } |
| 160 | |
| 161 | /// A field code naming `address`, which cannot hold its quotes or line breaks. |
| 162 | pub(super) fn field_code(address: &str) -> String { |
| 163 | let address: String = address |
| 164 | .trim() |
| 165 | .chars() |
| 166 | .filter(|c| !c.is_control() && *c != '\u{fddf}') |
| 167 | .map(|c| { |
| 168 | if c == '"' { |
| 169 | "%22".to_owned() |
| 170 | } else { |
| 171 | c.to_string() |
| 172 | } |
| 173 | }) |
| 174 | .collect(); |
| 175 | format!("\u{fddf}HYPERLINK \"{address}\"") |
| 176 | } |
| 177 | |
| 178 | /// A range of a paragraph the Link dialog links, with the text it shows and the address it |
| 179 | /// holds. |
| 180 | type Linked = (usize, Range<u32>, String, String); |
| 181 | |
| 182 | impl CanvasEditor { |
| 183 | /// The link in the active outline at `position`, its ends included. |
| 184 | pub fn link_at(&self, position: TextPosition) -> Option<Link> { |
| 185 | let text = self |
| 186 | .active_outline() |
| 187 | .document |
| 188 | .paragraph(position.paragraph)?; |
| 189 | links(text, position.paragraph).into_iter().find(|link| { |
| 190 | link.range().contains(&position.offset) || link.label.end == position.offset |
| 191 | }) |
| 192 | } |
| 193 | |
| 194 | /// The address of the link drawn under document point `point` of outline `id`, which a |
| 195 | /// click opens as OneNote's does. |
| 196 | pub(crate) fn link_under(&self, id: ExGuid, [x, y]: [f32; 2]) -> Option<String> { |
| 197 | let outline = self.visible_outlines().find(|outline| outline.id == id)?; |
| 198 | let index = outline.paragraph_at(x, y).ok()?; |
| 199 | let paragraph = &outline.shaped.paragraphs[index]; |
| 200 | let point = [x - paragraph.origin[0], y - paragraph.origin[1]]; |
| 201 | let cursor = paragraph.text.hit_test(point[0], point[1]); |
| 202 | let edge = paragraph.text.caret(cursor, 0.0).x0 as f32; |
| 203 | let visible = paragraph.projection.text(); |
| 204 | let byte = cursor.index(); |
| 205 | // The character under the point lies on the side of the nearest boundary it is on. |
| 206 | let at = if point[0] < edge { |
| 207 | visible.text()[..byte].char_indices().next_back()?.0 |
| 208 | } else { |
| 209 | visible.text()[byte..].chars().next()?; |
| 210 | byte |
| 211 | }; |
| 212 | let (line, _) = paragraph |
| 213 | .text |
| 214 | .lines() |
| 215 | .find(|(_, bounds)| bounds.top <= point[1] && point[1] < bounds.top + bounds.height)?; |
| 216 | if point[0] > line.metrics().advance { |
| 217 | return None; |
| 218 | } |
| 219 | let source = paragraph |
| 220 | .projection |
| 221 | .source_offset( |
| 222 | visible.utf16_offset(at).ok()?, |
| 223 | onestore::page::text::Affinity::Downstream, |
| 224 | ) |
| 225 | .ok()?; |
| 226 | let paragraph = outline.source_index(index); |
| 227 | links(outline.document.paragraph(paragraph)?, paragraph) |
| 228 | .into_iter() |
| 229 | .find(|link| link.label.contains(&source)) |
| 230 | .map(|link| link.target) |
| 231 | .or_else(|| { |
| 232 | shown_urls(visible) |
| 233 | .into_iter() |
| 234 | .find(|url| url.contains(&at)) |
| 235 | .map(|url| visible.text()[url].to_owned()) |
| 236 | }) |
| 237 | } |
| 238 | |
| 239 | /// What the Link dialog links, as OneNote 2010 picks it: the selection within one |
| 240 | /// paragraph, else the link or word at the caret, else nothing at the caret. With the |
| 241 | /// text it shows and the address it holds; none for a selection across paragraphs, which |
| 242 | /// OneNote's Link leaves alone. |
| 243 | fn link_range(&self) -> Result<Option<Linked>, EditError> { |
| 244 | let [anchor, focus] = self.active_outline().selection.positions; |
| 245 | let (start, end) = (anchor.min(focus), anchor.max(focus)); |
| 246 | if start.paragraph != end.paragraph { |
| 247 | return Ok(None); |
| 248 | } |
| 249 | let text = self |
| 250 | .active_outline() |
| 251 | .document |
| 252 | .paragraph(start.paragraph) |
| 253 | .ok_or(EditError::InvalidRange)?; |
| 254 | let shown = |range: Range<u32>| -> Result<String, EditError> { |
| 255 | Ok(text.slice(range)?.project()?.text().text().to_owned()) |
| 256 | }; |
| 257 | if start != end { |
| 258 | let mut range = start.offset..end.offset; |
| 259 | // Links the selection touches are relinked whole. |
| 260 | for link in links(text, start.paragraph) { |
| 261 | let whole = link.range(); |
| 262 | if whole.start < range.end && range.start < whole.end { |
| 263 | range = range.start.min(whole.start)..range.end.max(whole.end); |
| 264 | } |
| 265 | } |
| 266 | let address = self |
| 267 | .link_at(start) |
| 268 | .filter(|link| link.range().start <= range.start && range.end <= link.label.end) |
| 269 | .map(|link| link.target) |
| 270 | .unwrap_or_default(); |
| 271 | return Ok(Some(( |
| 272 | start.paragraph, |
| 273 | range.clone(), |
| 274 | shown(range)?, |
| 275 | address, |
| 276 | ))); |
| 277 | } |
| 278 | if let Some(link) = self.link_at(start) { |
| 279 | let label = shown(link.label.clone())?; |
| 280 | return Ok(Some((start.paragraph, link.range(), label, link.target))); |
| 281 | } |
| 282 | // The word at the caret, within plain visible text. |
| 283 | let byte = text.byte_offset(start.offset)?; |
| 284 | let plain = |at: usize, c: char| { |
| 285 | !c.is_whitespace() |
| 286 | && text |
| 287 | .utf16_offset(at) |
| 288 | .ok() |
| 289 | .and_then(|at| format_after(text, at)) |
| 290 | .is_some_and(|format| { |
| 291 | ![format.hidden, format.hyperlink, format.math].contains(&Some(true)) |
| 292 | }) |
| 293 | }; |
| 294 | let from = text.text()[..byte] |
| 295 | .char_indices() |
| 296 | .rev() |
| 297 | .take_while(|&(at, c)| plain(at, c)) |
| 298 | .last() |
| 299 | .map_or(byte, |(at, _)| at); |
| 300 | let to = text.text()[byte..] |
| 301 | .char_indices() |
| 302 | .take_while(|&(at, c)| plain(byte + at, c)) |
| 303 | .last() |
| 304 | .map_or(byte, |(at, c)| byte + at + c.len_utf8()); |
| 305 | let range = text.utf16_offset(from)?..text.utf16_offset(to)?; |
| 306 | Ok(Some(( |
| 307 | start.paragraph, |
| 308 | range.clone(), |
| 309 | shown(range)?, |
| 310 | String::new(), |
| 311 | ))) |
| 312 | } |
| 313 | |
| 314 | /// The text and address the Link dialog opens with; none where it does not open, over a |
| 315 | /// selection across paragraphs. |
| 316 | pub fn link_prefill(&self) -> Option<(String, String)> { |
| 317 | match self.link_range() { |
| 318 | Ok(Some((_, _, text, address))) => Some((text, address)), |
| 319 | Ok(None) => None, |
| 320 | Err(_) => Some(Default::default()), |
| 321 | } |
| 322 | } |
| 323 | |
| 324 | /// OK in the Link dialog: what [`Self::link_prefill`] picked becomes `text`, or the |
| 325 | /// address where `text` is empty, linked to `address`, and the caret follows it. |
| 326 | pub fn set_link( |
| 327 | &mut self, |
| 328 | engine: &mut TextEngine, |
| 329 | text: &str, |
| 330 | address: &str, |
| 331 | ) -> Result<(), EditorError> { |
| 332 | if address.trim().is_empty() { |
| 333 | return Ok(()); |
| 334 | } |
| 335 | let Some((paragraph, range, _, _)) = self.link_range()? else { |
| 336 | return Ok(()); |
| 337 | }; |
| 338 | let current = self |
| 339 | .active_outline() |
| 340 | .document |
| 341 | .paragraph(paragraph) |
| 342 | .ok_or(EditError::InvalidRange)?; |
| 343 | let label: String = if text.is_empty() { |
| 344 | address.trim() |
| 345 | } else { |
| 346 | text |
| 347 | } |
| 348 | .chars() |
| 349 | .map(|c| if c.is_control() { ' ' } else { c }) |
| 350 | .collect(); |
| 351 | let mut base = match format_after(current, range.start).filter(|_| !range.is_empty()) { |
| 352 | Some(format) => format.clone(), |
| 353 | None => self.typing_format(TextPosition { |
| 354 | paragraph, |
| 355 | offset: range.start, |
| 356 | })?, |
| 357 | }; |
| 358 | unlinked(&mut base); |
| 359 | base.hidden = Some(false); |
| 360 | let mut code = base.clone(); |
| 361 | code.hidden = Some(true); |
| 362 | code.hyperlink = Some(true); |
| 363 | code.hyperlink_label = Some(true); |
| 364 | let mut shown = base; |
| 365 | shown.hyperlink = Some(true); |
| 366 | shown.hyperlink_label = Some(true); |
| 367 | let replacement = Paragraph::from_runs([(field_code(address), code), (label, shown)]); |
| 368 | let length = replacement.utf16_offset(replacement.text().len())?; |
| 369 | let mut updated = current.clone(); |
| 370 | updated.apply(onestore::page::text::Edit { |
| 371 | range: range.clone(), |
| 372 | replacement, |
| 373 | })?; |
| 374 | let caret = TextPosition { |
| 375 | paragraph, |
| 376 | offset: range.start + length, |
| 377 | }; |
| 378 | self.rewrite(engine, paragraph, updated, [caret; 2].into()) |
| 379 | } |
| 380 | |
| 381 | /// Remove Link: the link at the caret keeps its label as plain text. |
| 382 | pub fn remove_link(&mut self, engine: &mut TextEngine) -> Result<bool, EditorError> { |
| 383 | let [anchor, focus] = self.active_outline().selection.positions; |
| 384 | let Some(link) = self.link_at(anchor.min(focus)) else { |
| 385 | return Ok(false); |
| 386 | }; |
| 387 | let text = self |
| 388 | .active_outline() |
| 389 | .document |
| 390 | .paragraph(link.paragraph) |
| 391 | .ok_or(EditError::InvalidRange)?; |
| 392 | let start = text.byte_offset(link.label.start)?; |
| 393 | let end = text.byte_offset(link.label.end)?; |
| 394 | let mut updated = restyle(text, start..end, unlinked); |
| 395 | let mut label = link.label.clone(); |
| 396 | if let Some(code) = &link.code { |
| 397 | updated.apply(onestore::page::text::Edit { |
| 398 | range: code.clone(), |
| 399 | replacement: Paragraph::new(String::new(), Format::default()), |
| 400 | })?; |
| 401 | label = label.start - code.len() as u32..label.end - code.len() as u32; |
| 402 | } |
| 403 | let at = |offset| TextPosition { |
| 404 | paragraph: link.paragraph, |
| 405 | offset, |
| 406 | }; |
| 407 | self.rewrite( |
| 408 | engine, |
| 409 | link.paragraph, |
| 410 | updated, |
| 411 | [at(label.start), at(label.end)].into(), |
| 412 | )?; |
| 413 | Ok(true) |
| 414 | } |
| 415 | |
| 416 | /// Select Link: the label of the link at the caret. |
| 417 | pub(crate) fn select_link(&mut self) -> Result<bool, EditError> { |
| 418 | let [anchor, focus] = self.active_outline().selection.positions; |
| 419 | let Some(link) = self.link_at(anchor.min(focus)) else { |
| 420 | return Ok(false); |
| 421 | }; |
| 422 | let at = |offset| TextPosition { |
| 423 | paragraph: link.paragraph, |
| 424 | offset, |
| 425 | }; |
| 426 | self.select([at(link.label.start), at(link.label.end)].into())?; |
| 427 | Ok(true) |
| 428 | } |
| 429 | |
| 430 | /// Links the URL typed before `end` once a space or Enter ends it, as its own undo step. |
| 431 | pub(super) fn link_typed_url( |
| 432 | &mut self, |
| 433 | engine: &mut TextEngine, |
| 434 | end: TextPosition, |
| 435 | ) -> Result<(), EditorError> { |
| 436 | let Some(text) = self.active_outline().document.paragraph(end.paragraph) else { |
| 437 | return Ok(()); |
| 438 | }; |
| 439 | let byte = text.byte_offset(end.offset)?; |
| 440 | let from = text.text()[..byte] |
| 441 | .char_indices() |
| 442 | .rev() |
| 443 | .take_while(|&(_, c)| !c.is_whitespace()) |
| 444 | .last() |
| 445 | .map_or(byte, |(at, _)| at); |
| 446 | let Some(url) = typed_url(&text.text()[from..byte]) else { |
| 447 | return Ok(()); |
| 448 | }; |
| 449 | let url = from + url.start..from + url.end; |
| 450 | // Only plain text links; a link, field code or equation there stays as it is. |
| 451 | let mut offset = text.utf16_offset(url.start)?; |
| 452 | for c in text.text()[url.clone()].chars() { |
| 453 | let format = format_after(text, offset).ok_or(EditError::InvalidRange)?; |
| 454 | if [format.hyperlink, format.hidden, format.math].contains(&Some(true)) { |
| 455 | return Ok(()); |
| 456 | } |
| 457 | offset += c.len_utf16() as u32; |
| 458 | } |
| 459 | let updated = restyle(text, url, |format| format.hyperlink = Some(true)); |
| 460 | let selection = self.active_outline().selection; |
| 461 | self.rewrite(engine, end.paragraph, updated, selection) |
| 462 | } |
| 463 | |
| 464 | /// Moves a caret between a field code and its label to before the code, so what is |
| 465 | /// typed there stays outside the link. |
| 466 | pub(super) fn leave_link_code(&mut self) -> Result<(), EditError> { |
| 467 | let selection = self.active_outline().selection; |
| 468 | let [anchor, focus] = selection.positions; |
| 469 | if anchor != focus { |
| 470 | return Ok(()); |
| 471 | } |
| 472 | let Some(link) = self.link_at(focus) else { |
| 473 | return Ok(()); |
| 474 | }; |
| 475 | if let Some(code) = link.code.filter(|code| code.end == focus.offset) { |
| 476 | let at = TextPosition { |
| 477 | paragraph: focus.paragraph, |
| 478 | offset: code.start, |
| 479 | }; |
| 480 | self.active_outline_mut().selection = [at; 2].into(); |
| 481 | } |
| 482 | Ok(()) |
| 483 | } |
| 484 | |
| 485 | /// Replaces the text of paragraph `paragraph` of the active outline as one undo step |
| 486 | /// ending at `selection`. |
| 487 | pub(super) fn rewrite( |
| 488 | &mut self, |
| 489 | engine: &mut TextEngine, |
| 490 | paragraph: usize, |
| 491 | text: Paragraph, |
| 492 | selection: Selection, |
| 493 | ) -> Result<(), EditorError> { |
| 494 | let (container, index, node) = self |
| 495 | .active_outline() |
| 496 | .document |
| 497 | .leaf(paragraph) |
| 498 | .ok_or(EditError::InvalidRange)?; |
| 499 | let mut node = node.clone(); |
| 500 | node.text_mut().unwrap().text = text; |
| 501 | self.commit( |
| 502 | engine, |
| 503 | DocumentEdit { |
| 504 | columns: BTreeMap::new(), |
| 505 | container, |
| 506 | range: index..index + 1, |
| 507 | replacement: vec![node], |
| 508 | }, |
| 509 | selection, |
| 510 | ) |
| 511 | } |
| 512 | } |
| 513 | |
| 514 | #[cfg(test)] |
| 515 | mod tests { |
| 516 | use super::*; |
| 517 | use onestore::page::PageObject; |
| 518 | |
| 519 | /// A paragraph's text as runs of (text, hyperlink, label, hidden), runs with the same |
| 520 | /// flags merged. |
| 521 | fn flags(text: &Paragraph) -> Vec<(String, bool, bool, bool)> { |
| 522 | let mut runs: Vec<(String, bool, bool, bool)> = Vec::new(); |
| 523 | let mut start = 0; |
| 524 | for span in text.spans() { |
| 525 | let format = &span.format; |
| 526 | let run = ( |
| 527 | text.text()[start..span.end].to_owned(), |
| 528 | format.hyperlink == Some(true), |
| 529 | format.hyperlink_label == Some(true), |
| 530 | format.hidden == Some(true), |
| 531 | ); |
| 532 | start = span.end; |
| 533 | match runs.last_mut() { |
| 534 | Some(last) if (last.1, last.2, last.3) == (run.1, run.2, run.3) => { |
| 535 | last.0.push_str(&run.0); |
| 536 | } |
| 537 | _ => runs.push(run), |
| 538 | } |
| 539 | } |
| 540 | runs |
| 541 | } |
| 542 | |
| 543 | fn native_paragraphs() -> Vec<Paragraph> { |
| 544 | let bytes = include_bytes!("../../../../corpus/link-edit/native-typed/notebook/links.one"); |
| 545 | let store = onestore::Store::parse(bytes).unwrap(); |
| 546 | let index = onestore::RevisionIndex::parse(&store).unwrap(); |
| 547 | let document = onestore::document::Document::parse(&index).unwrap(); |
| 548 | let (space, _) = document.pages().unwrap()[0]; |
| 549 | let page = Page::from_space(&document, space).unwrap(); |
| 550 | page.objects |
| 551 | .iter() |
| 552 | .filter_map(|object| match object { |
| 553 | PageObject::Outline(outline) if !outline.title => Some(outline), |
| 554 | _ => None, |
| 555 | }) |
| 556 | .flat_map(|outline| &outline.paragraphs) |
| 557 | .filter_map(|paragraph| paragraph.text().map(|text| text.text.clone())) |
| 558 | .skip(1) |
| 559 | .collect() |
| 560 | } |
| 561 | |
| 562 | /// Types `text`, each space on its own as a key does. |
| 563 | fn typed(editor: &mut CanvasEditor, engine: &mut TextEngine, text: &str) { |
| 564 | for (index, word) in text.split(' ').enumerate() { |
| 565 | if index > 0 { |
| 566 | editor.insert(engine, " ").unwrap(); |
| 567 | } |
| 568 | if !word.is_empty() { |
| 569 | editor.insert(engine, word).unwrap(); |
| 570 | } |
| 571 | } |
| 572 | } |
| 573 | |
| 574 | fn at(paragraph: usize, offset: u32) -> Selection { |
| 575 | [TextPosition { paragraph, offset }; 2].into() |
| 576 | } |
| 577 | |
| 578 | /// The editor replays what `tools/native_links.py` typed into OneNote 2010 and stores |
| 579 | /// the same links: typed URLs, the Link dialog on a word, on a selection and on nothing, |
| 580 | /// typing after a link, and Remove Link. |
| 581 | #[test] |
| 582 | fn link_editing_stores_what_onenote_stored() { |
| 583 | let native = native_paragraphs(); |
| 584 | let mut engine = TextEngine::default(); |
| 585 | let mut editor = CanvasEditor::new( |
| 586 | &mut engine, |
| 587 | TextDocument::new(vec![Paragraph::new(String::new(), Format::default())]).unwrap(), |
| 588 | 400.0, |
| 589 | ) |
| 590 | .unwrap(); |
| 591 | let engine = &mut engine; |
| 592 | for url in [ |
| 593 | "www.example.com", |
| 594 | "http://example.org/a?b=1", |
| 595 | "https://a.example/p.", |
| 596 | "(http://b.example/q)", |
| 597 | "ftp://c.example/f", |
| 598 | "mailto:me@example.com", |
| 599 | "me@example.com", |
| 600 | "example.com", |
| 601 | "onenote:#Page&section-id={x}", |
| 602 | "file://c:/windows", |
| 603 | "\\\\server\\share\\f", |
| 604 | "news:comp.lang", |
| 605 | "www.d.example,", |
| 606 | "HTTP://E.EXAMPLE/UP", |
| 607 | "https://f.example/a_b-c~d%20e#frag", |
| 608 | ] { |
| 609 | // OneNote capitalized the first letter of each sentence as it was typed. |
| 610 | let after = if url.ends_with('.') { "Z" } else { "z" }; |
| 611 | typed(&mut editor, engine, &format!("T {url} {after}")); |
| 612 | editor.enter(engine, false).unwrap(); |
| 613 | } |
| 614 | typed(&mut editor, engine, "See http://g.example/end"); |
| 615 | editor.enter(engine, false).unwrap(); |
| 616 | typed(&mut editor, engine, "Zplain words here"); |
| 617 | let paragraph = editor.selection().positions[0].paragraph; |
| 618 | editor.select(at(paragraph, 13)).unwrap(); |
| 619 | assert_eq!(editor.link_prefill(), Some(("here".into(), String::new()))); |
| 620 | editor.set_link(engine, "here", "example.net/x").unwrap(); |
| 621 | typed(&mut editor, engine, "Q"); |
| 622 | editor.enter(engine, false).unwrap(); |
| 623 | typed(&mut editor, engine, "Select me please"); |
| 624 | let paragraph = editor.selection().positions[0].paragraph; |
| 625 | editor |
| 626 | .select( |
| 627 | [ |
| 628 | TextPosition { |
| 629 | paragraph, |
| 630 | offset: 7, |
| 631 | }, |
| 632 | TextPosition { |
| 633 | paragraph, |
| 634 | offset: 16, |
| 635 | }, |
| 636 | ] |
| 637 | .into(), |
| 638 | ) |
| 639 | .unwrap(); |
| 640 | assert_eq!( |
| 641 | editor.link_prefill(), |
| 642 | Some(("me please".into(), String::new())) |
| 643 | ); |
| 644 | editor |
| 645 | .set_link(engine, "me please", "https://example.com/selected") |
| 646 | .unwrap(); |
| 647 | editor.enter(engine, false).unwrap(); |
| 648 | assert_eq!(editor.link_prefill(), Some((String::new(), String::new()))); |
| 649 | editor |
| 650 | .set_link(engine, "", "https://example.com/only") |
| 651 | .unwrap(); |
| 652 | typed(&mut editor, engine, " tail"); |
| 653 | editor.enter(engine, false).unwrap(); |
| 654 | typed(&mut editor, engine, "Gone ftp://h.example/f z"); |
| 655 | let paragraph = editor.selection().positions[0].paragraph; |
| 656 | editor.select(at(paragraph, 8)).unwrap(); |
| 657 | assert!(editor.remove_link(engine).unwrap()); |
| 658 | let written: Vec<_> = editor |
| 659 | .active_outline() |
| 660 | .document |
| 661 | .text_nodes() |
| 662 | .map(|node| flags(&node.text().unwrap().text)) |
| 663 | .collect(); |
| 664 | let expected: Vec<_> = native.iter().map(flags).collect(); |
| 665 | assert_eq!(written, expected); |
| 666 | } |
| 667 | |
| 668 | /// Every URL OneNote 2010 linked or left as typed in `corpus/link-edit/native-typed`. |
| 669 | #[test] |
| 670 | fn typed_urls_link_as_onenote_links_them() { |
| 671 | for (word, linked) in [ |
| 672 | ("www.example.com", Some("www.example.com")), |
| 673 | ("http://example.org/a?b=1", Some("http://example.org/a?b=1")), |
| 674 | ("https://a.example/p.", Some("https://a.example/p")), |
| 675 | ("(http://b.example/q)", Some("http://b.example/q")), |
| 676 | ("ftp://c.example/f", Some("ftp://c.example/f")), |
| 677 | ("mailto:me@example.com", Some("mailto:me@example.com")), |
| 678 | ("me@example.com", None), |
| 679 | ("example.com", None), |
| 680 | ( |
| 681 | "onenote:#Page&section-id={x}", |
| 682 | Some("onenote:#Page&section-id={x}"), |
| 683 | ), |
| 684 | ("file://c:/windows", Some("file://c:/windows")), |
| 685 | ("\\\\server\\share\\f", Some("\\\\server\\share\\f")), |
| 686 | ("news:comp.lang", Some("news:comp.lang")), |
| 687 | ("www.d.example,", Some("www.d.example")), |
| 688 | ("HTTP://E.EXAMPLE/UP", Some("HTTP://E.EXAMPLE/UP")), |
| 689 | ( |
| 690 | "https://f.example/a_b-c~d%20e#frag", |
| 691 | Some("https://f.example/a_b-c~d%20e#frag"), |
| 692 | ), |
| 693 | ("wwWw.example.com", None), |
| 694 | ("http://", None), |
| 695 | ] { |
| 696 | assert_eq!(typed_url(word).map(|range| &word[range]), linked, "{word}"); |
| 697 | } |
| 698 | } |
| 699 | } |