use std::fs::File;
use std::io::{BufReader, Read, Write};
use std::path::PathBuf;
use std::time::Instant;
use anyhow::{Context, Result};
use serde::Serialize;
use crate::cli::GlobalArgs;
use crate::error::AtomwriteError;
use crate::output::NdjsonWriter;
pub(crate) const DEFAULT_MAX_SAMPLE_ROWS: usize = 10;
pub(crate) const DEFAULT_MAX_TEXT_CHARS: usize = 400;
pub(crate) const CELL_CHAR_CAP: usize = 60;
pub(crate) const HEAD_PROBE_BYTES: usize = 64;
pub(crate) const MAX_SHEETS: usize = 32;
pub(crate) const MAX_SLIDES: usize = 64;
#[derive(clap::Args, Debug)]
pub struct ExtractArtifactArgs {
#[arg(value_hint = clap::ValueHint::FilePath)]
pub path: PathBuf,
#[arg(long, value_name = "N")]
pub max_sample_rows: Option<usize>,
#[arg(long, value_name = "N")]
pub max_text_chars: Option<usize>,
}
#[derive(Debug, Serialize, schemars::JsonSchema)]
pub struct ArtifactOutput {
pub r#type: &'static str,
pub path: String,
pub kind: &'static str,
pub format: &'static str,
pub bytes: u64,
#[serde(skip_serializing_if = "Option::is_none")]
pub pages: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub title: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub author: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub producer: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub width: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub height: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub megapixels: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub width_final: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub height_final: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub visual_tokens: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub paragraphs: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub text_chars: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub text_excerpt: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub sheets: Option<Vec<SheetSignal>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub slides: Option<Vec<SlideSignal>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub truncated_parts: Option<u64>,
pub elapsed_ms: u64,
}
#[derive(Debug, Serialize, schemars::JsonSchema)]
pub struct SheetSignal {
pub name: String,
pub rows: u64,
pub columns: u64,
pub header: Vec<String>,
pub types: Vec<String>,
pub sample: Vec<Vec<String>>,
}
#[derive(Debug, Serialize, schemars::JsonSchema)]
pub struct SlideSignal {
pub index: u64,
pub title: String,
pub text_chars: u64,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum Container {
Pdf,
Zip,
Image,
}
pub fn cmd_extract_artifact(
args: &ExtractArtifactArgs,
global: &GlobalArgs,
writer: &mut NdjsonWriter<impl Write>,
) -> Result<()> {
let workspace = global.resolve_workspace()?;
let path = crate::path_safety::validate_path(&args.path, &workspace)?;
let tuning = crate::config::tuning();
let output = extract_signal(
&path,
args.max_sample_rows
.unwrap_or(tuning.extract_max_sample_rows),
args.max_text_chars.unwrap_or(tuning.extract_max_text_chars),
global.effective_max_filesize(),
)?;
writer.write_event(&output)?;
Ok(())
}
pub(crate) fn extract_signal(
path: &std::path::Path,
max_sample_rows: usize,
max_text_chars: usize,
max_part: u64,
) -> Result<ArtifactOutput> {
let start = Instant::now();
if !path.exists() {
return Err(AtomwriteError::NotFound {
path: path.to_path_buf(),
}
.into());
}
if path.is_dir() {
return Err(AtomwriteError::InvalidInput {
reason: format!(
"{} is a directory; extract-artifact reads one file — \
use `list` for a directory inventory",
path.display()
),
}
.into());
}
let bytes = std::fs::metadata(path)
.with_context(|| format!("cannot stat {}", path.display()))?
.len();
let head = read_head(path)?;
let mut output = ArtifactOutput {
r#type: "artifact",
path: path.display().to_string(),
kind: "unknown",
format: "unknown",
bytes,
pages: None,
title: None,
author: None,
producer: None,
width: None,
height: None,
megapixels: None,
width_final: None,
height_final: None,
visual_tokens: None,
paragraphs: None,
text_chars: None,
text_excerpt: None,
sheets: None,
slides: None,
truncated_parts: None,
elapsed_ms: 0,
};
match detect_container(&head) {
Some(Container::Pdf) => extract_pdf(path, max_text_chars, max_part, &mut output)?,
Some(Container::Zip) => {
extract_office(path, max_sample_rows, max_text_chars, max_part, &mut output)?;
}
Some(Container::Image) => extract_image(path, &head, &mut output)?,
None => {
return Err(unsupported(
path,
"no supported magic bytes (expected PDF, an OOXML container, or an image header)",
));
}
}
output.elapsed_ms = start.elapsed().as_millis() as u64;
Ok(output)
}
fn read_head(path: &std::path::Path) -> Result<Vec<u8>> {
let file = File::open(path).with_context(|| format!("cannot open {}", path.display()))?;
let mut head = vec![0u8; HEAD_PROBE_BYTES];
let mut filled = 0usize;
let mut reader = BufReader::new(file);
while filled < head.len() {
let n = reader
.read(&mut head[filled..])
.with_context(|| format!("cannot read {}", path.display()))?;
if n == 0 {
break;
}
filled += n;
}
head.truncate(filled);
Ok(head)
}
fn detect_container(head: &[u8]) -> Option<Container> {
if head.starts_with(b"%PDF-") {
return Some(Container::Pdf);
}
if head.starts_with(b"PK\x03\x04")
|| head.starts_with(b"PK\x05\x06")
|| head.starts_with(b"PK\x07\x08")
{
return Some(Container::Zip);
}
if crate::binary_detect::is_binary(head) && imagesize::image_type(head).is_ok() {
return Some(Container::Image);
}
None
}
pub(crate) fn corrupt(path: &std::path::Path, reason: impl std::fmt::Display) -> anyhow::Error {
AtomwriteError::InvalidInput {
reason: format!("corrupt artifact {}: {reason}", path.display()),
}
.into()
}
pub(crate) fn unsupported(path: &std::path::Path, reason: &str) -> anyhow::Error {
AtomwriteError::InvalidInput {
reason: format!(
"unsupported artifact {}: {reason}. Supported: pdf, docx, xlsx, pptx, \
png, jpeg, gif, bmp, webp, tiff, ico",
path.display()
),
}
.into()
}
pub(crate) fn truncate_chars(text: &str, cap: usize) -> String {
if text.chars().count() <= cap {
return text.to_string();
}
text.chars().take(cap).collect()
}
use quick_xml::Reader;
use quick_xml::XmlVersion;
use quick_xml::events::Event;
fn xml_reader(xml: &str) -> Reader<&[u8]> {
let mut reader = Reader::from_str(xml);
reader.config_mut().trim_text(true);
reader
}
fn local_name(name: quick_xml::name::QName<'_>) -> String {
name.local_name().as_ref().to_ascii_lowercase()
}
fn text_of(event: &Event<'_>) -> Option<String> {
match event {
Event::Text(t) => Some(t.xml_content(XmlVersion::Implicit1_0).into_owned()),
Event::CData(c) => Some(c.as_ref().to_string()),
_ => None,
}
}
fn attr_value(element: &quick_xml::events::BytesStart<'_>, key: &[u8]) -> Option<String> {
let key = std::str::from_utf8(key).ok()?;
for attribute in element.attributes().flatten() {
if attribute.key.as_ref() == key {
return Some(
attribute
.normalized_value(XmlVersion::Implicit1_0)
.map(|c| c.into_owned())
.unwrap_or_else(|_| attribute.value.to_string()),
);
}
}
None
}
include!("office.inc.rs");
include!("pdf.inc.rs");
include!("image.inc.rs");
include!("tests.inc.rs");