use std::fs::File;
use std::io::{BufWriter, Write};
use std::path::{Path, PathBuf};
use std::sync::Mutex;
use crate::planner::{PyramidPlan, TileCoord};
use crate::raster::Raster;
use crate::sink::{SinkError, Tile, TileFormat, TileSink, encode_png};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PackfileFormat {
Tar,
TarGz,
Zip,
}
pub struct PackfileSink {
out_path: PathBuf,
format: PackfileFormat,
plan: PyramidPlan,
tile_format: TileFormat,
writer: Mutex<Option<ArchiveWriter>>,
}
enum ArchiveWriter {
Tar(tar::Builder<BufWriter<File>>),
TarGz(Box<tar::Builder<flate2::write::GzEncoder<BufWriter<File>>>>),
Zip(Box<zip::ZipWriter<BufWriter<File>>>),
}
impl std::fmt::Debug for PackfileSink {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("PackfileSink")
.field("out_path", &self.out_path)
.field("format", &self.format)
.field("tile_format", &self.tile_format)
.finish()
}
}
impl PackfileSink {
pub fn builder(path: impl Into<PathBuf>) -> PackfileSinkBuilder {
PackfileSinkBuilder {
out_path: path.into(),
format: PackfileFormat::Tar,
tile_format: TileFormat::Png,
plan: None,
}
}
pub fn new(
path: impl Into<PathBuf>,
format: PackfileFormat,
plan: PyramidPlan,
tile_format: TileFormat,
) -> Result<Self, SinkError> {
let out_path = path.into();
if let Some(parent) = out_path.parent() {
if !parent.as_os_str().is_empty() {
std::fs::create_dir_all(parent)?;
}
}
let file = File::create(&out_path)?;
let buffered = BufWriter::new(file);
let writer = match format {
PackfileFormat::Tar => {
let builder = tar::Builder::new(buffered);
ArchiveWriter::Tar(builder)
}
PackfileFormat::TarGz => {
let gz = flate2::write::GzEncoder::new(buffered, flate2::Compression::default());
ArchiveWriter::TarGz(Box::new(tar::Builder::new(gz)))
}
PackfileFormat::Zip => ArchiveWriter::Zip(Box::new(zip::ZipWriter::new(buffered))),
};
Ok(Self {
out_path,
format,
plan,
tile_format,
writer: Mutex::new(Some(writer)),
})
}
pub fn out_path(&self) -> &Path {
&self.out_path
}
pub fn format(&self) -> PackfileFormat {
self.format
}
fn archive_stem(&self) -> String {
let file_name = self
.out_path
.file_name()
.map(|s| s.to_string_lossy().into_owned())
.unwrap_or_else(|| "archive".to_string());
if let Some(rest) = file_name.strip_suffix(".tar.gz") {
rest.to_string()
} else if let Some(rest) = file_name.strip_suffix(".tgz") {
rest.to_string()
} else {
Path::new(&file_name)
.file_stem()
.map(|s| s.to_string_lossy().into_owned())
.unwrap_or(file_name)
}
}
fn tile_archive_path(&self, coord: TileCoord) -> Option<String> {
let rel = self.plan.tile_path(coord, self.tile_format.extension())?;
let stem = self.archive_stem();
Some(format!("{stem}_files/{rel}"))
}
fn encode_tile(&self, raster: &Raster) -> Result<Vec<u8>, SinkError> {
match self.tile_format {
TileFormat::Raw => Ok(raster.data().to_vec()),
TileFormat::Png => encode_png(raster),
TileFormat::Jpeg { quality } => encode_jpeg(raster, quality),
}
}
fn append_bytes(&self, archive_path: &str, bytes: &[u8]) -> Result<(), SinkError> {
let mut guard = self
.writer
.lock()
.map_err(|e| SinkError::Other(format!("packfile writer mutex poisoned: {e}")))?;
let writer = guard
.as_mut()
.ok_or_else(|| SinkError::Other("packfile already finished".to_string()))?;
match writer {
ArchiveWriter::Tar(builder) => append_tar(builder, archive_path, bytes),
ArchiveWriter::TarGz(builder) => append_tar(builder, archive_path, bytes),
ArchiveWriter::Zip(zw) => append_zip(zw, archive_path, bytes),
}
}
fn build_manifest_json(&self) -> String {
let stem = self.archive_stem();
let ext = self.tile_format.extension();
let layout = format!("{:?}", self.plan.layout);
let mut levels_json = String::from("[");
for (i, level) in self.plan.levels.iter().enumerate() {
if i > 0 {
levels_json.push(',');
}
levels_json.push_str(&format!(
"{{\"level\":{},\"width\":{},\"height\":{},\"cols\":{},\"rows\":{}}}",
level.level, level.width, level.height, level.cols, level.rows
));
}
levels_json.push(']');
format!(
"{{\n \
\"schema\": \"libviprs.packfile.v0\",\n \
\"stem\": {stem:?},\n \
\"tile_format\": {ext:?},\n \
\"tile_size\": {tile_size},\n \
\"overlap\": {overlap},\n \
\"image_width\": {width},\n \
\"image_height\": {height},\n \
\"layout\": {layout:?},\n \
\"tile_prefix\": \"{stem}_files\",\n \
\"levels\": {levels_json}\n\
}}\n",
tile_size = self.plan.tile_size,
overlap = self.plan.overlap,
width = self.plan.image_width,
height = self.plan.image_height,
)
}
}
#[derive(Debug, Clone)]
pub struct PackfileSinkBuilder {
out_path: PathBuf,
format: PackfileFormat,
tile_format: TileFormat,
plan: Option<PyramidPlan>,
}
impl PackfileSinkBuilder {
pub fn format(mut self, format: PackfileFormat) -> Self {
self.format = format;
self
}
pub fn tile_format(mut self, tile_format: TileFormat) -> Self {
self.tile_format = tile_format;
self
}
pub fn plan(mut self, plan: PyramidPlan) -> Self {
self.plan = Some(plan);
self
}
pub fn build(self) -> Result<PackfileSink, SinkError> {
let plan = self
.plan
.ok_or(SinkError::MissingField("PackfileSinkBuilder::plan"))?;
PackfileSink::new(self.out_path, self.format, plan, self.tile_format)
}
}
impl TileSink for PackfileSink {
fn write_tile(&self, tile: &Tile) -> Result<(), SinkError> {
if tile.blank {
return Ok(());
}
let dzi_path = self
.tile_archive_path(tile.coord)
.ok_or_else(|| SinkError::Other(format!("invalid coord {:?}", tile.coord)))?;
let encoded = self.encode_tile(&tile.raster)?;
self.append_bytes(&dzi_path, &encoded)?;
let rel = self
.plan
.tile_path(tile.coord, self.tile_format.extension())
.expect("tile_archive_path succeeded above");
let stem_path = format!("{}/{}", self.archive_stem(), rel);
self.append_bytes(&stem_path, &encoded)?;
Ok(())
}
fn finish(&self) -> Result<(), SinkError> {
let stem = self.archive_stem();
let manifest = self.build_manifest_json();
self.append_bytes("manifest.json", manifest.as_bytes())?;
if let Some(dzi) = self.plan.dzi_manifest(self.tile_format.extension()) {
let dzi_path = format!("{stem}.dzi");
self.append_bytes(&dzi_path, dzi.as_bytes())?;
}
let mut guard = self
.writer
.lock()
.map_err(|e| SinkError::Other(format!("packfile writer mutex poisoned: {e}")))?;
let writer = guard
.take()
.ok_or_else(|| SinkError::Other("packfile already finished".to_string()))?;
match writer {
ArchiveWriter::Tar(mut builder) => {
builder.finish()?;
let inner = builder.into_inner().map_err(SinkError::Io)?;
let file = inner
.into_inner()
.map_err(|e| SinkError::Io(e.into_error()))?;
drop(file);
}
ArchiveWriter::TarGz(mut builder) => {
builder.finish()?;
let gz = builder.into_inner().map_err(SinkError::Io)?;
let inner = gz.finish()?;
let file = inner
.into_inner()
.map_err(|e| SinkError::Io(e.into_error()))?;
drop(file);
}
ArchiveWriter::Zip(zw) => {
let inner = zw
.finish()
.map_err(|e| SinkError::Other(format!("zip finalize error: {e}")))?;
let file = inner
.into_inner()
.map_err(|e| SinkError::Io(e.into_error()))?;
drop(file);
}
}
Ok(())
}
}
fn append_tar<W: Write>(
builder: &mut tar::Builder<W>,
path: &str,
bytes: &[u8],
) -> Result<(), SinkError> {
let mut header = tar::Header::new_gnu();
header.set_size(bytes.len() as u64);
header.set_mode(0o644);
header.set_mtime(0);
header.set_entry_type(tar::EntryType::Regular);
header.set_cksum();
builder
.append_data(&mut header, path, bytes)
.map_err(SinkError::Io)
}
fn append_zip<W: Write + std::io::Seek>(
zw: &mut zip::ZipWriter<W>,
path: &str,
bytes: &[u8],
) -> Result<(), SinkError> {
let options = zip::write::SimpleFileOptions::default()
.compression_method(zip::CompressionMethod::Deflated);
zw.start_file(path, options)
.map_err(|e| SinkError::Other(format!("zip start_file error: {e}")))?;
zw.write_all(bytes).map_err(SinkError::Io)?;
Ok(())
}
fn encode_jpeg(raster: &Raster, quality: u8) -> Result<Vec<u8>, SinkError> {
use crate::pixel::PixelFormat;
let ct = match raster.format() {
PixelFormat::Gray8 => image::ColorType::L8,
PixelFormat::Gray16 => image::ColorType::L16,
PixelFormat::Rgb8 => image::ColorType::Rgb8,
PixelFormat::Rgba8 => image::ColorType::Rgba8,
PixelFormat::Rgb16 => image::ColorType::Rgb16,
PixelFormat::Rgba16 => image::ColorType::Rgba16,
};
let mut buf = Vec::new();
let encoder =
image::codecs::jpeg::JpegEncoder::new_with_quality(std::io::Cursor::new(&mut buf), quality);
image::ImageEncoder::write_image(
encoder,
raster.data(),
raster.width(),
raster.height(),
ct.into(),
)
.map_err(|e| SinkError::EncodeMsg(format!("png: {e}")))?;
Ok(buf)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::pixel::PixelFormat;
use crate::planner::{Layout, PyramidPlanner};
fn make_plan(w: u32, h: u32, tile: u32) -> PyramidPlan {
PyramidPlanner::new(w, h, tile, 0, Layout::DeepZoom)
.unwrap()
.plan()
}
#[test]
fn packfile_sink_is_send_sync() {
fn assert_send_sync<T: Send + Sync>() {}
assert_send_sync::<PackfileSink>();
}
#[test]
#[cfg_attr(miri, ignore)] fn archive_stem_handles_common_suffixes() {
let plan = make_plan(64, 64, 32);
let dir = tempfile::tempdir().unwrap();
for (file_name, format, expected) in [
("output.tar", PackfileFormat::Tar, "output"),
("pyramid.tar.gz", PackfileFormat::TarGz, "pyramid"),
("bundle.zip", PackfileFormat::Zip, "bundle"),
] {
let path = dir.path().join(file_name);
let sink =
PackfileSink::new(path.clone(), format, plan.clone(), TileFormat::Png).unwrap();
assert_eq!(
sink.archive_stem(),
expected,
"stem for {file_name:?} ({format:?}) was {:?}",
sink.archive_stem()
);
}
}
#[test]
#[cfg_attr(miri, ignore)] fn tile_archive_path_uses_deep_zoom_layout() {
let plan = make_plan(128, 128, 64);
let top_level = plan.levels.last().unwrap().level;
let dir = tempfile::tempdir().unwrap();
let sink = PackfileSink::new(
dir.path().join("out.tar"),
PackfileFormat::Tar,
plan,
TileFormat::Png,
)
.unwrap();
let p = sink
.tile_archive_path(TileCoord::new(top_level, 0, 0))
.unwrap();
assert_eq!(p, format!("out_files/{top_level}/0_0.png"));
}
#[test]
#[cfg_attr(miri, ignore)] fn manifest_json_contains_structural_fields() {
let plan = make_plan(128, 128, 64);
let dir = tempfile::tempdir().unwrap();
let sink = PackfileSink::new(
dir.path().join("out.tar"),
PackfileFormat::Tar,
plan,
TileFormat::Png,
)
.unwrap();
let manifest = sink.build_manifest_json();
let _parsed: serde_json::Value =
serde_json::from_str(&manifest).expect("manifest.json must be valid JSON");
assert!(manifest.contains("\"schema\""));
assert!(manifest.contains("\"tile_format\""));
assert!(manifest.contains("\"levels\""));
assert!(manifest.contains("\"tile_prefix\""));
}
#[test]
#[cfg_attr(miri, ignore)] fn end_to_end_tar_smoke() {
let plan = make_plan(64, 64, 32);
let top = plan.levels.last().unwrap();
let dir = tempfile::tempdir().unwrap();
let out = dir.path().join("smoke.tar");
let sink = PackfileSink::new(
out.clone(),
PackfileFormat::Tar,
plan.clone(),
TileFormat::Png,
)
.unwrap();
let tile = Tile {
coord: TileCoord::new(top.level, 0, 0),
raster: Raster::zeroed(32, 32, PixelFormat::Rgb8).unwrap(),
blank: false,
};
sink.write_tile(&tile).unwrap();
sink.finish().unwrap();
let meta = std::fs::metadata(&out).unwrap();
assert!(meta.len() > 0, "tar archive must be non-empty");
}
}