Skip to main content

provenant/utils/file/
extract.rs

1// SPDX-FileCopyrightText: nexB Inc. and others
2// ScanCode is a trademark of nexB Inc.
3// SPDX-FileCopyrightText: Provenant contributors
4// SPDX-License-Identifier: Apache-2.0
5// Derived from ScanCode Toolkit (Apache-2.0); modified. See NOTICE.
6
7//! Text extraction for downstream license/copyright detection: chooses a
8//! strategy per input (RTF, PDF, image metadata, font metadata, decoded text,
9//! or bounded binary-string scraping) and augments markdown/HTML license hints.
10
11use std::borrow::Cow;
12use std::collections::BTreeSet;
13use std::path::Path;
14
15use object::FileKind;
16
17use crate::parsers::windows_executable::extract_windows_executable_metadata_text;
18use crate::utils::font::extract_font_metadata_text;
19use crate::utils::language::detect_language;
20
21use super::encoding::{
22    decode_bytes_to_string_with_diagnostic, looks_like_decoded_text, looks_like_textual_bytes,
23};
24use super::format_sniff::{
25    detect_file_format, is_supported_image_container, is_textual_format, is_zip_archive,
26    looks_like_bzip2, looks_like_deb, looks_like_gzip, looks_like_pdf, looks_like_rpm,
27    looks_like_rtf, looks_like_squashfs, looks_like_xz, media_mime_from_content,
28    supported_image_metadata_format,
29};
30use super::image_metadata::extract_image_metadata_text;
31use super::path::{PLAIN_TEXT_EXTENSIONS, lower_extension};
32use super::pdf::extract_pdf_text;
33
34pub(super) const LARGE_OPAQUE_BINARY_SKIP_BYTES: usize = 512 * 1024;
35const LARGE_MACHO_LEGAL_WINDOW_BYTES: usize = 64 * 1024;
36const LARGE_MACHO_LEGAL_MAX_WINDOWS: usize = 24;
37const LARGE_MACHO_LEGAL_MAX_WINDOWS_PER_MARKER: usize = 4;
38const LARGE_MACHO_LEGAL_MAX_EXTRACT_BYTES: usize = 2 * 1024 * 1024;
39const LARGE_MACHO_LEGAL_MARKERS: &[&[u8]] = &[
40    b"Unicode, Inc.",
41    b"http://www.unicode.org/copyright.html",
42    b"https://www.unicode.org/copyright.html",
43    b"SPDX-License-Identifier:",
44    b"Licensed under",
45    b"licensed under",
46    b"Apache License",
47    b"http://www.apache.org/licenses/",
48    b"https://www.apache.org/licenses/",
49    b"Permission is hereby granted",
50    b"permission is hereby granted",
51    b"Redistribution and use in source and binary forms",
52    b"redistribution and use in source and binary forms",
53    b"Permission to use, copy, modify, and/or distribute this software",
54    b"The MIT License",
55    b"GNU GENERAL PUBLIC LICENSE",
56    b"GNU LESSER GENERAL PUBLIC LICENSE",
57    b"Mozilla Public License",
58];
59
60#[derive(Debug, Clone, Copy, PartialEq, Eq)]
61pub enum ExtractedTextKind {
62    None,
63    Decoded,
64    FontMetadata,
65    Pdf,
66    BinaryStrings,
67    ImageMetadata,
68    WindowsExecutableMetadata,
69}
70
71pub fn extract_text_for_detection(path: &Path, bytes: &[u8]) -> (String, ExtractedTextKind) {
72    let (text, kind, _) = extract_text_for_detection_with_diagnostics(path, bytes);
73    (text, kind)
74}
75
76pub(crate) fn augment_license_detection_text<'a>(path: &Path, text: &'a str) -> Cow<'a, str> {
77    let Some(extension) = path.extension().and_then(|ext| ext.to_str()) else {
78        return Cow::Borrowed(text);
79    };
80    if !matches!(
81        extension.to_ascii_lowercase().as_str(),
82        "md" | "markdown" | "html" | "htm"
83    ) {
84        return Cow::Borrowed(text);
85    }
86
87    let mut hints = Vec::new();
88    let has_dual_license_notice = has_dual_license_notice_text(text);
89    if text.contains("CC BY 4.0") || text.contains("creativecommons.org/licenses/by/4.0") {
90        hints.push("Creative Commons Attribution 4.0 International License".to_string());
91    }
92    if !has_dual_license_notice
93        && (text.contains("Apache License (Version 2.0)")
94            || text.contains("Apache License, Version 2.0"))
95    {
96        hints.push(
97            "Licensed under the Apache License, Version 2.0. http://www.apache.org/licenses/LICENSE-2.0"
98                .to_string(),
99        );
100    }
101
102    if !has_dual_license_notice {
103        hints.extend(extract_shields_license_badge_hints(text));
104    }
105
106    if hints.is_empty() {
107        Cow::Borrowed(text)
108    } else {
109        let mut augmented =
110            String::with_capacity(text.len() + hints.iter().map(String::len).sum::<usize>() + 8);
111        augmented.push_str(text);
112        augmented.push_str("\n\n");
113        for (index, hint) in hints.into_iter().enumerate() {
114            if index > 0 {
115                augmented.push('\n');
116            }
117            augmented.push_str(&hint);
118        }
119        Cow::Owned(augmented)
120    }
121}
122
123fn extract_shields_license_badge_hints(text: &str) -> Vec<String> {
124    let mut hints = Vec::new();
125    let mut rest = text;
126    let needle = "img.shields.io/badge/license-";
127
128    while let Some(index) = rest.find(needle) {
129        let start = index + needle.len();
130        let suffix = &rest[start..];
131        let end = suffix
132            .find([')', ']', '"', '\'', ' ', '\n'])
133            .unwrap_or(suffix.len());
134        let badge = &suffix[..end];
135        let Some(badge) = badge.strip_suffix(".svg") else {
136            rest = &suffix[end..];
137            continue;
138        };
139
140        let mut segments: Vec<_> = badge
141            .split('-')
142            .filter(|segment| !segment.is_empty())
143            .collect();
144        if segments.len() < 2 {
145            rest = &suffix[end..];
146            continue;
147        }
148        segments.pop();
149        let candidate = segments.join("-").replace("%20", " ").replace('_', "-");
150        if !candidate.is_empty() {
151            hints.push(canonical_shields_license_hint(&candidate));
152        }
153
154        rest = &suffix[end..];
155    }
156
157    hints.sort();
158    hints.dedup();
159    hints
160}
161
162fn has_dual_license_notice_text(text: &str) -> bool {
163    let lower = text.to_ascii_lowercase();
164    (lower.contains("licensed under either of") && lower.contains("at your option"))
165        || lower.contains("dual-licensed under")
166        || lower.contains("dual licensed under")
167}
168
169fn canonical_shields_license_hint(candidate: &str) -> String {
170    match candidate.trim() {
171        "MIT" => "The MIT License".to_string(),
172        "Apache-2.0" | "Apache 2.0" => "Apache License 2.0".to_string(),
173        other => format!("{other} License"),
174    }
175}
176
177pub(crate) fn extract_text_for_detection_with_diagnostics(
178    path: &Path,
179    bytes: &[u8],
180) -> (String, ExtractedTextKind, Option<String>) {
181    let ext = path
182        .extension()
183        .and_then(|e| e.to_str())
184        .map(|s| s.to_ascii_lowercase());
185    let detected_format = detect_file_format(bytes);
186
187    if looks_like_rtf(bytes, ext.as_deref()) {
188        let text = extract_rtf_text(bytes);
189        return if text.trim().is_empty() {
190            (String::new(), ExtractedTextKind::None, None)
191        } else {
192            (text, ExtractedTextKind::Decoded, None)
193        };
194    }
195
196    if looks_like_pdf(bytes) || detected_format.short_name() == Some("PDF") {
197        let (text, scan_error) = extract_pdf_text(path, bytes);
198        return if text.is_empty() {
199            (String::new(), ExtractedTextKind::None, scan_error)
200        } else {
201            (text, ExtractedTextKind::Pdf, None)
202        };
203    }
204
205    if let Some(format) = supported_image_metadata_format(ext.as_deref(), detected_format) {
206        let text = extract_image_metadata_text(bytes, format);
207        return if text.is_empty() {
208            if is_supported_image_container(bytes, format) {
209                (String::new(), ExtractedTextKind::None, None)
210            } else {
211                let (decoded, decode_diagnostic) = decode_bytes_to_string_with_diagnostic(bytes);
212                if decoded.is_empty() {
213                    (String::new(), ExtractedTextKind::None, decode_diagnostic)
214                } else {
215                    (decoded, ExtractedTextKind::Decoded, None)
216                }
217            }
218        } else {
219            (text, ExtractedTextKind::ImageMetadata, None)
220        };
221    }
222
223    if let Some(text) = extract_font_metadata_text(path, bytes) {
224        let strings = extract_printable_strings(bytes);
225        let combined = if strings.is_empty() {
226            text
227        } else {
228            combine_extracted_text_fragments(Some(text), strings)
229        };
230        return (combined, ExtractedTextKind::FontMetadata, None);
231    }
232
233    let windows_executable_metadata_text = extract_windows_executable_metadata_text(bytes);
234    let large_opaque_binary = windows_executable_metadata_text.is_none()
235        && is_large_opaque_binary_candidate(bytes, detected_format);
236    let bounded_macho_legal_text = if large_opaque_binary {
237        extract_bounded_macho_legal_strings(bytes)
238    } else {
239        String::new()
240    };
241    let skip_large_opaque_binary_text =
242        should_skip_large_opaque_binary_text_extraction(path, bytes, detected_format);
243
244    if skip_large_opaque_binary_text {
245        if !bounded_macho_legal_text.is_empty() {
246            return (
247                combine_extracted_text_fragments(
248                    windows_executable_metadata_text,
249                    bounded_macho_legal_text,
250                ),
251                ExtractedTextKind::BinaryStrings,
252                None,
253            );
254        }
255        return windows_metadata_or_empty_result(windows_executable_metadata_text);
256    }
257
258    if should_skip_binary_string_extraction(path, bytes, detected_format) {
259        return (String::new(), ExtractedTextKind::None, None);
260    }
261
262    let is_svg_text = lower_extension(path).as_deref() == Some("svg")
263        || detected_format.media_type() == "image/svg+xml";
264    let should_try_decoded_text = looks_like_textual_bytes(bytes) || is_svg_text;
265    let decoded_is_utf8 = std::str::from_utf8(bytes).is_ok();
266    let path_suggests_text = ext.as_deref().is_some_and(|extension| {
267        PLAIN_TEXT_EXTENSIONS.contains(&extension) || detect_language(path, bytes).is_some()
268    });
269
270    let mut decode_diagnostic = None;
271    if !large_opaque_binary && should_try_decoded_text {
272        let (decoded, diagnostic) = decode_bytes_to_string_with_diagnostic(bytes);
273        decode_diagnostic = diagnostic;
274        if !decoded.is_empty()
275            && (is_svg_text
276                || decoded_is_utf8
277                || path_suggests_text
278                || looks_like_decoded_text(&decoded))
279        {
280            let combined =
281                combine_extracted_text_fragments(windows_executable_metadata_text, decoded);
282            return (combined, ExtractedTextKind::Decoded, None);
283        }
284    }
285
286    let text = if large_opaque_binary {
287        let sampled_text = extract_sampled_printable_strings(bytes);
288        if bounded_macho_legal_text.is_empty() {
289            sampled_text
290        } else {
291            combine_extracted_text_fragments(Some(sampled_text), bounded_macho_legal_text)
292        }
293    } else {
294        extract_printable_strings(bytes)
295    };
296    if text.is_empty() {
297        let (result_text, result_kind, result_diagnostic) =
298            windows_metadata_or_empty_result(windows_executable_metadata_text);
299        // Only surface the near-binary skip when nothing else recovered text and
300        // the file truly drops out of detection.
301        let result_diagnostic = if result_text.is_empty() {
302            result_diagnostic.or(decode_diagnostic)
303        } else {
304            result_diagnostic
305        };
306        (result_text, result_kind, result_diagnostic)
307    } else {
308        (
309            combine_extracted_text_fragments(windows_executable_metadata_text, text),
310            ExtractedTextKind::BinaryStrings,
311            None,
312        )
313    }
314}
315
316fn combine_extracted_text_fragments(prefix: Option<String>, suffix: String) -> String {
317    match prefix {
318        Some(prefix) if !prefix.is_empty() && !suffix.is_empty() => format!("{prefix}\n{suffix}"),
319        Some(prefix) if !prefix.is_empty() => prefix,
320        _ => suffix,
321    }
322}
323
324pub(super) fn windows_metadata_or_empty_result(
325    windows_executable_metadata_text: Option<String>,
326) -> (String, ExtractedTextKind, Option<String>) {
327    if let Some(metadata_text) = windows_executable_metadata_text {
328        (
329            metadata_text,
330            ExtractedTextKind::WindowsExecutableMetadata,
331            None,
332        )
333    } else {
334        (String::new(), ExtractedTextKind::None, None)
335    }
336}
337
338fn extract_rtf_text(bytes: &[u8]) -> String {
339    let text = String::from_utf8_lossy(bytes);
340    let chars: Vec<char> = text.chars().collect();
341    let mut output = String::new();
342    let mut index = 0usize;
343
344    while index < chars.len() {
345        match chars[index] {
346            '{' | '}' => {
347                index += 1;
348            }
349            '\\' => {
350                index += 1;
351                if index >= chars.len() {
352                    break;
353                }
354
355                match chars[index] {
356                    '\\' | '{' | '}' => {
357                        output.push(chars[index]);
358                        index += 1;
359                    }
360                    '\'' => {
361                        if index + 2 < chars.len() {
362                            let hex = [chars[index + 1], chars[index + 2]];
363                            let hex: String = hex.iter().collect();
364                            if let Ok(value) = u8::from_str_radix(&hex, 16) {
365                                output.push(value as char);
366                                index += 3;
367                                continue;
368                            }
369                        }
370                        index += 1;
371                    }
372                    control if control.is_ascii_alphabetic() => {
373                        let start = index;
374                        while index < chars.len() && chars[index].is_ascii_alphabetic() {
375                            index += 1;
376                        }
377                        let control_word: String = chars[start..index].iter().collect();
378
379                        let number_start = index;
380                        if index < chars.len()
381                            && (chars[index] == '-' || chars[index].is_ascii_digit())
382                        {
383                            index += 1;
384                            while index < chars.len() && chars[index].is_ascii_digit() {
385                                index += 1;
386                            }
387                        }
388                        let parameter: String = chars[number_start..index].iter().collect();
389
390                        if index < chars.len() && chars[index] == ' ' {
391                            index += 1;
392                        }
393
394                        match control_word.as_str() {
395                            "par" | "line" => output.push('\n'),
396                            "tab" => output.push('\t'),
397                            "emdash" => output.push('—'),
398                            "endash" => output.push('–'),
399                            "bullet" => output.push('•'),
400                            "lquote" | "rquote" => output.push('\''),
401                            "ldblquote" | "rdblquote" => output.push('"'),
402                            "u" => {
403                                if let Ok(codepoint) = parameter.parse::<i32>() {
404                                    let normalized = if codepoint < 0 {
405                                        codepoint + 65_536
406                                    } else {
407                                        codepoint
408                                    };
409                                    if let Ok(normalized) = u32::try_from(normalized)
410                                        && let Some(ch) = char::from_u32(normalized)
411                                    {
412                                        output.push(ch);
413                                    }
414                                }
415
416                                if index < chars.len()
417                                    && !matches!(chars[index], '\\' | '{' | '}' | '\n' | '\r')
418                                {
419                                    index += 1;
420                                }
421                            }
422                            _ => {}
423                        }
424                    }
425                    _ => {
426                        index += 1;
427                    }
428                }
429            }
430            ch => {
431                output.push(ch);
432                index += 1;
433            }
434        }
435    }
436
437    output
438        .replace(['\r', '\u{0c}'], "\n")
439        .lines()
440        .map(str::trim_end)
441        .collect::<Vec<_>>()
442        .join("\n")
443}
444
445fn should_skip_binary_string_extraction(
446    path: &Path,
447    bytes: &[u8],
448    detected_format: file_format::FileFormat,
449) -> bool {
450    use file_format::Kind as FileFormatKind;
451    matches!(lower_extension(path).as_deref(), Some("pdf"))
452        || supported_image_metadata_format(lower_extension(path).as_deref(), detected_format)
453            .is_some()
454        || (matches!(
455            detected_format.kind(),
456            FileFormatKind::Audio | FileFormatKind::Image | FileFormatKind::Video
457        ) && !is_textual_format(detected_format))
458        || media_mime_from_content(bytes).is_some()
459        || is_zip_archive(bytes)
460        || looks_like_gzip(bytes)
461        || looks_like_bzip2(bytes)
462        || looks_like_xz(bytes)
463        || looks_like_deb(bytes, path)
464        || looks_like_rpm(bytes, path)
465        || looks_like_squashfs(bytes, path)
466}
467
468fn should_skip_large_opaque_binary_text_extraction(
469    _path: &Path,
470    bytes: &[u8],
471    detected_format: file_format::FileFormat,
472) -> bool {
473    is_large_opaque_binary_candidate(bytes, detected_format)
474        && !sample_has_promising_printable_strings(bytes)
475}
476
477fn is_large_opaque_binary_candidate(
478    bytes: &[u8],
479    detected_format: file_format::FileFormat,
480) -> bool {
481    use file_format::Kind as FileFormatKind;
482    bytes.len() >= LARGE_OPAQUE_BINARY_SKIP_BYTES
483        && !is_textual_format(detected_format)
484        && !matches!(
485            detected_format.kind(),
486            FileFormatKind::Archive
487                | FileFormatKind::Compressed
488                | FileFormatKind::Package
489                | FileFormatKind::Audio
490                | FileFormatKind::Image
491                | FileFormatKind::Video
492        )
493}
494
495fn sampled_printable_window_ranges(len: usize) -> Vec<(usize, usize)> {
496    const SAMPLE_WINDOW_BYTES: usize = 64 * 1024;
497
498    let mut ranges = Vec::new();
499    let mut push_range = |start: usize, end: usize| {
500        if start < end && !ranges.contains(&(start, end)) {
501            ranges.push((start, end));
502        }
503    };
504
505    push_range(0, len.min(SAMPLE_WINDOW_BYTES));
506    if len > SAMPLE_WINDOW_BYTES * 2 {
507        let mid_start = len / 2 - SAMPLE_WINDOW_BYTES / 2;
508        let mid_end = (mid_start + SAMPLE_WINDOW_BYTES).min(len);
509        push_range(mid_start, mid_end);
510    }
511    if len > SAMPLE_WINDOW_BYTES {
512        push_range(len - SAMPLE_WINDOW_BYTES, len);
513    }
514
515    ranges
516}
517
518fn extract_bounded_macho_legal_strings(bytes: &[u8]) -> String {
519    if !matches!(
520        FileKind::parse(bytes),
521        Ok(FileKind::MachO32 | FileKind::MachO64 | FileKind::MachOFat32 | FileKind::MachOFat64)
522    ) {
523        return String::new();
524    }
525
526    let mut ranges = Vec::new();
527    for marker in LARGE_MACHO_LEGAL_MARKERS {
528        collect_marker_window_ranges(bytes, marker, &mut ranges);
529        if ranges.len() >= LARGE_MACHO_LEGAL_MAX_WINDOWS {
530            break;
531        }
532    }
533
534    if ranges.is_empty() {
535        return String::new();
536    }
537
538    let mut merged_ranges = merge_overlapping_ranges(ranges);
539    let mut combined_lines = BTreeSet::new();
540    let mut extracted_bytes = 0usize;
541
542    for (start, end) in merged_ranges.drain(..) {
543        if extracted_bytes >= LARGE_MACHO_LEGAL_MAX_EXTRACT_BYTES {
544            break;
545        }
546        let remaining = LARGE_MACHO_LEGAL_MAX_EXTRACT_BYTES - extracted_bytes;
547        let end = start.saturating_add((end - start).min(remaining));
548        let window_text = extract_printable_strings(&bytes[start..end]);
549        for line in window_text
550            .lines()
551            .map(str::trim)
552            .filter(|line| !line.is_empty())
553        {
554            combined_lines.insert(line.to_string());
555        }
556        extracted_bytes += end - start;
557    }
558
559    combined_lines.into_iter().collect::<Vec<_>>().join("\n")
560}
561
562fn collect_marker_window_ranges(bytes: &[u8], marker: &[u8], ranges: &mut Vec<(usize, usize)>) {
563    if marker.is_empty() || ranges.len() >= LARGE_MACHO_LEGAL_MAX_WINDOWS {
564        return;
565    }
566
567    let mut search_start = 0usize;
568    let mut hits_for_marker = 0usize;
569
570    while search_start + marker.len() <= bytes.len()
571        && ranges.len() < LARGE_MACHO_LEGAL_MAX_WINDOWS
572        && hits_for_marker < LARGE_MACHO_LEGAL_MAX_WINDOWS_PER_MARKER
573    {
574        let Some(relative_match) = bytes[search_start..].iter().position(|&b| b == marker[0])
575        else {
576            break;
577        };
578        let match_start = search_start + relative_match;
579        let match_end = match_start + marker.len();
580        if match_end <= bytes.len() && &bytes[match_start..match_end] == marker {
581            let half_window = LARGE_MACHO_LEGAL_WINDOW_BYTES / 2;
582            let window_start = match_start.saturating_sub(half_window);
583            let window_end = (match_end + half_window).min(bytes.len());
584            ranges.push((window_start, window_end));
585            hits_for_marker += 1;
586            search_start = match_end;
587        } else {
588            search_start = match_start + 1;
589        }
590    }
591}
592
593fn merge_overlapping_ranges(mut ranges: Vec<(usize, usize)>) -> Vec<(usize, usize)> {
594    if ranges.is_empty() {
595        return ranges;
596    }
597
598    ranges.sort_unstable_by_key(|&(start, end)| (start, end));
599
600    let mut merged = Vec::with_capacity(ranges.len());
601    let mut current = ranges[0];
602    for (start, end) in ranges.into_iter().skip(1) {
603        if start <= current.1 {
604            current.1 = current.1.max(end);
605        } else {
606            merged.push(current);
607            current = (start, end);
608        }
609    }
610    merged.push(current);
611
612    merged
613}
614
615fn sample_has_promising_printable_strings(bytes: &[u8]) -> bool {
616    let mut structured_signal_seen = false;
617    let promising_license_windows = sampled_printable_window_ranges(bytes.len())
618        .into_iter()
619        .filter(|&(start, end)| {
620            let window = &bytes[start..end];
621            if has_strong_structured_text_signal(window) {
622                structured_signal_seen = true;
623            }
624            has_license_or_notice_signal(window)
625        })
626        .count();
627
628    structured_signal_seen || promising_license_windows >= 2
629}
630
631fn extract_sampled_printable_strings(bytes: &[u8]) -> String {
632    let mut combined_lines = BTreeSet::new();
633
634    for (start, end) in sampled_printable_window_ranges(bytes.len()) {
635        let window_text = extract_printable_strings(&bytes[start..end]);
636        for line in window_text
637            .lines()
638            .map(str::trim)
639            .filter(|line| !line.is_empty())
640        {
641            combined_lines.insert(line.to_string());
642        }
643    }
644
645    combined_lines.into_iter().collect::<Vec<_>>().join("\n")
646}
647
648fn has_license_or_notice_signal(bytes: &[u8]) -> bool {
649    let strings = extract_printable_strings(bytes);
650    if strings.is_empty() {
651        return false;
652    }
653
654    let lower = strings.to_ascii_lowercase();
655    [
656        "copyright",
657        "license",
658        "licensed under",
659        "all rights reserved",
660        "permission is hereby granted",
661        "redistribution and use",
662        "spdx-license-identifier",
663    ]
664    .iter()
665    .any(|marker| lower.contains(marker))
666}
667
668fn has_strong_structured_text_signal(bytes: &[u8]) -> bool {
669    let strings = extract_printable_strings(bytes);
670    if strings.is_empty() {
671        return false;
672    }
673
674    let email_markers = strings.matches('@').count();
675    let url_markers = strings.matches("http://").count() + strings.matches("https://").count();
676
677    email_markers + url_markers >= 3
678}
679
680pub fn extract_printable_strings(bytes: &[u8]) -> String {
681    const MIN_LEN: usize = 4;
682    const MIN_OUTPUT_BYTES: usize = 2_000_000;
683    const MAX_OUTPUT_BYTES_CAP: usize = 16_000_000;
684
685    let max_output_bytes = bytes.len().clamp(MIN_OUTPUT_BYTES, MAX_OUTPUT_BYTES_CAP);
686
687    fn is_printable_ascii(b: u8) -> bool {
688        matches!(b, 0x20..=0x7E)
689    }
690
691    let mut out = String::new();
692    let mut run: Vec<u8> = Vec::new();
693
694    let flush_run = |out: &mut String, run: &mut Vec<u8>| {
695        if run.len() >= MIN_LEN {
696            if !out.is_empty() {
697                out.push('\n');
698            }
699            out.push_str(&String::from_utf8_lossy(run));
700        }
701        run.clear();
702    };
703
704    for &b in bytes {
705        if is_printable_ascii(b) {
706            run.push(b);
707        } else {
708            flush_run(&mut out, &mut run);
709            if out.len() >= max_output_bytes {
710                return out;
711            }
712        }
713    }
714    flush_run(&mut out, &mut run);
715    if out.len() >= max_output_bytes {
716        return out;
717    }
718
719    for start in 0..=1 {
720        run.clear();
721        let mut i = start;
722        while i + 1 < bytes.len() {
723            let b0 = bytes[i];
724            let b1 = bytes[i + 1];
725            let (ch, zero) = if start == 0 { (b0, b1) } else { (b1, b0) };
726            if is_printable_ascii(ch) && zero == 0 {
727                run.push(ch);
728            } else {
729                flush_run(&mut out, &mut run);
730                if out.len() >= max_output_bytes {
731                    return out;
732                }
733            }
734            i += 2;
735        }
736        flush_run(&mut out, &mut run);
737        if out.len() >= max_output_bytes {
738            return out;
739        }
740    }
741
742    out
743}