Ein endloses Notizbuch hat eine einzige Seite von 10 × 10 m; Eingabe, Treffertests, Undo und Speichern bleiben dadurch unverändert. Die Ansicht zeigt bei 100 % einen DIN-A4-Rahmen in der Mitte, Bilder und Textfelder richten sich nach A4. Wählbar beim Anlegen und im ☰-Menü, umschalten nur bei einer Seite ohne PDF bzw. zurück, solange alles im Startausschnitt liegt. Seiten-Knöpfe sind auf der endlosen Fläche aus. Der Export enthält nur den beschriebenen Bereich mit 10 mm Rand, auf das Musterraster gelegt; Bilder einer Auswahl rastern nur noch den Ausschnitt statt der ganzen Seite. Formatversion 9. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
445 lines
16 KiB
Rust
445 lines
16 KiB
Rust
//! Export einzelner Seiten als PNG oder SVG und einer Auswahl als Bild.
|
||
//!
|
||
//! Grundlage ist immer der PDF-Export: die Seite wird als einseitiges PDF erzeugt (mit
|
||
//! Bildern, importierten PDF-Seiten und Formen) und anschließend mit `hayro` gerastert bzw.
|
||
//! mit `hayro-svg` nach SVG umgewandelt. So sehen PDF, PNG und SVG gleich aus.
|
||
|
||
use std::error::Error;
|
||
|
||
use hayro::hayro_interpret::InterpreterSettings;
|
||
use hayro::hayro_syntax::Pdf;
|
||
use hayro::vello_cpu::color::palette::css::{TRANSPARENT, WHITE};
|
||
use hayro::{PixmapSettings, RenderCache, RenderSettings};
|
||
|
||
use crate::assets::{AssetStore, RgbaImage};
|
||
use crate::background::PatternColor;
|
||
use crate::document::{Aabb, Notebook, Page, PageBackground};
|
||
use crate::export::{export_notebook_to_bytes, ExportStrategy};
|
||
use crate::t;
|
||
|
||
/// Auflösung des PNG-Exports einer Seite.
|
||
pub const PAGE_PNG_DPI: f32 = 200.0;
|
||
/// Auflösung einer in die Zwischenablage kopierten Auswahl.
|
||
pub const SELECTION_PNG_DPI: f32 = 150.0;
|
||
|
||
/// Hintergrund beim Bild-Export.
|
||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||
pub enum ImageBackground {
|
||
/// Weißes Papier mit Raster bzw. importierter PDF-Seite
|
||
Paper,
|
||
/// Transparent, nur Bilder und Tinte (ohne Raster und ohne importierte PDF-Seite)
|
||
Transparent,
|
||
}
|
||
|
||
/// Erzeugt ein einseitiges PDF aus `page`.
|
||
fn single_page_pdf(
|
||
page: &Page,
|
||
assets: Option<&AssetStore>,
|
||
pattern: PatternColor,
|
||
) -> Result<Vec<u8>, Box<dyn Error>> {
|
||
let notebook = Notebook::from_pages(vec![page.clone()], 0, 1, page.id + 1);
|
||
export_notebook_to_bytes(¬ebook, ExportStrategy::FilledPolygon, assets, pattern)
|
||
}
|
||
|
||
/// Seite ohne Raster und PDF-Hintergrund (für transparenten Export und Auswahlen).
|
||
fn content_only(page: &Page) -> Page {
|
||
let mut page = page.clone();
|
||
page.background = PageBackground::Blank;
|
||
page.pdf_background = None;
|
||
page
|
||
}
|
||
|
||
/// Rastert die erste Seite eines PDFs mit `dpi`; Farben ohne Vormultiplikation.
|
||
fn rasterize(pdf_bytes: Vec<u8>, dpi: f32, transparent: bool) -> Result<RgbaImage, Box<dyn Error>> {
|
||
let pdf = Pdf::new(pdf_bytes).map_err(|e| t!("PDF nicht lesbar: {e}", e = format!("{e:?}")))?;
|
||
let page = pdf.pages().first().ok_or(t!("PDF ohne Seite"))?;
|
||
let scale = dpi / 72.0;
|
||
let pixmap = hayro::render(
|
||
page,
|
||
&RenderCache::new(),
|
||
&InterpreterSettings::default(),
|
||
&RenderSettings::default(),
|
||
&PixmapSettings {
|
||
x_scale: scale,
|
||
y_scale: scale,
|
||
bg_color: if transparent { TRANSPARENT } else { WHITE },
|
||
},
|
||
);
|
||
let mut pixels = pixmap.data_as_u8_slice().to_vec();
|
||
// hayro liefert vormultiplizierte Farben; PNG erwartet gerade Farben
|
||
for px in pixels.as_chunks_mut::<4>().0 {
|
||
let a = px[3] as u32;
|
||
if a > 0 && a < 255 {
|
||
for c in &mut px[..3] {
|
||
*c = ((*c as u32 * 255 + a / 2) / a).min(255) as u8;
|
||
}
|
||
}
|
||
}
|
||
Ok(RgbaImage {
|
||
width: pixmap.width() as u32,
|
||
height: pixmap.height() as u32,
|
||
pixels,
|
||
})
|
||
}
|
||
|
||
/// Kodiert ein Bild als PNG.
|
||
pub fn encode_png(image: &RgbaImage) -> Result<Vec<u8>, Box<dyn Error>> {
|
||
let buffer = image::RgbaImage::from_raw(image.width, image.height, image.pixels.clone())
|
||
.ok_or(t!("Pixeldaten passen nicht zur Bildgröße"))?;
|
||
let mut out = Vec::new();
|
||
image::DynamicImage::ImageRgba8(buffer)
|
||
.write_to(&mut std::io::Cursor::new(&mut out), image::ImageFormat::Png)?;
|
||
Ok(out)
|
||
}
|
||
|
||
/// Seite als PNG (A4 mit [`PAGE_PNG_DPI`]).
|
||
pub fn page_png(
|
||
page: &Page,
|
||
assets: Option<&AssetStore>,
|
||
background: ImageBackground,
|
||
pattern: PatternColor,
|
||
) -> Result<Vec<u8>, Box<dyn Error>> {
|
||
let image = match background {
|
||
ImageBackground::Paper => {
|
||
rasterize(single_page_pdf(page, assets, pattern)?, PAGE_PNG_DPI, false)?
|
||
}
|
||
ImageBackground::Transparent => rasterize(
|
||
single_page_pdf(&content_only(page), assets, pattern)?,
|
||
PAGE_PNG_DPI,
|
||
true,
|
||
)?,
|
||
};
|
||
encode_png(&image)
|
||
}
|
||
|
||
/// Seite als SVG (Vektor; Schriften importierter PDFs werden zu Pfaden).
|
||
pub fn page_svg(
|
||
page: &Page,
|
||
assets: Option<&AssetStore>,
|
||
pattern: PatternColor,
|
||
) -> Result<String, Box<dyn Error>> {
|
||
let pdf = Pdf::new(single_page_pdf(page, assets, pattern)?)
|
||
.map_err(|e| t!("PDF nicht lesbar: {e}", e = format!("{e:?}")))?;
|
||
let pdf_page = pdf.pages().first().ok_or(t!("PDF ohne Seite"))?;
|
||
Ok(hayro_svg::convert(
|
||
pdf_page,
|
||
&hayro_svg::RenderCache::new(),
|
||
&InterpreterSettings::default(),
|
||
&hayro_svg::SvgRenderSettings {
|
||
bg_color: [255, 255, 255, 255],
|
||
},
|
||
))
|
||
}
|
||
|
||
/// Bild der Objekte `ids` auf `page`, auf ihren Umriss zugeschnitten, mit transparentem
|
||
/// Hintergrund. `None`, wenn keines der Objekte auf der Seite liegt.
|
||
pub fn selection_image(
|
||
page: &Page,
|
||
ids: &[u64],
|
||
assets: Option<&AssetStore>,
|
||
) -> Result<Option<RgbaImage>, Box<dyn Error>> {
|
||
let mut selected = content_only(page);
|
||
selected.strokes.retain(|s| ids.contains(&s.id));
|
||
let Some(bounds) = Page::selection_aabb(&selected.strokes) else {
|
||
return Ok(None);
|
||
};
|
||
let max_width = selected.strokes.iter().map(|s| s.width).fold(0.0, f32::max);
|
||
let bounds: Aabb = bounds.expanded(max_width / 2.0 + 4.0);
|
||
let size = bounds.max - bounds.min;
|
||
if size.min_element() < 1.0 {
|
||
return Ok(None);
|
||
}
|
||
// Nur den Ausschnitt rastern (auf der endlosen Seite wäre die ganze Seite riesig)
|
||
let cropped = selected.cropped(bounds.min, size);
|
||
let image = rasterize(
|
||
single_page_pdf(&cropped, assets, PatternColor::default())?,
|
||
SELECTION_PNG_DPI,
|
||
true,
|
||
)?;
|
||
Ok(Some(image))
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
use crate::document::{Stroke, StylusSample, A4_WIDTH_MM, PAGE_HEIGHT, PAGE_WIDTH};
|
||
|
||
fn page_with_line() -> Page {
|
||
let mut page = Page::new(1, PageBackground::Grid5mm);
|
||
let samples: Vec<StylusSample> = (0..=20)
|
||
.map(|i| StylusSample {
|
||
position: glam::Vec2::new(500.0 + i as f32 * 20.0, 1000.0),
|
||
pressure: 0.6,
|
||
timestamp: i as f64 * 0.01,
|
||
})
|
||
.collect();
|
||
let vertices = crate::storage::reconstruct_stroke_vertices(
|
||
&samples,
|
||
[0.9, 0.1, 0.1, 1.0],
|
||
20.0,
|
||
Default::default(),
|
||
true,
|
||
);
|
||
let mut stroke = Stroke::new(7, samples, vertices, [0.9, 0.1, 0.1, 1.0]);
|
||
stroke.width = 20.0;
|
||
page.strokes.push(stroke);
|
||
page
|
||
}
|
||
|
||
fn decode(png: &[u8]) -> image::RgbaImage {
|
||
image::load_from_memory(png).unwrap().into_rgba8()
|
||
}
|
||
|
||
#[test]
|
||
fn test_page_png_paper_and_transparent() {
|
||
let page = page_with_line();
|
||
let paper = decode(
|
||
&page_png(&page, None, ImageBackground::Paper, PatternColor::default()).unwrap(),
|
||
);
|
||
// A4 mit 200 dpi
|
||
assert_eq!(paper.width(), (A4_WIDTH_MM / 25.4 * PAGE_PNG_DPI) as u32);
|
||
assert_eq!(paper.get_pixel(5, 5).0[3], 255, "Papier ist deckend");
|
||
|
||
let clear = decode(
|
||
&page_png(
|
||
&page,
|
||
None,
|
||
ImageBackground::Transparent,
|
||
PatternColor::default(),
|
||
)
|
||
.unwrap(),
|
||
);
|
||
assert_eq!(clear.get_pixel(5, 5).0[3], 0, "Hintergrund ist transparent");
|
||
// Die Linie ist rot und deckend
|
||
let px_per_unit = clear.width() as f32 / PAGE_WIDTH;
|
||
let line = clear.get_pixel((700.0 * px_per_unit) as u32, (1000.0 * px_per_unit) as u32);
|
||
assert!(
|
||
line.0[0] > 180 && line.0[1] < 80 && line.0[3] > 200,
|
||
"{line:?}"
|
||
);
|
||
assert!(clear.height() as f32 / clear.width() as f32 - PAGE_HEIGHT / PAGE_WIDTH < 0.01);
|
||
}
|
||
|
||
#[test]
|
||
fn test_highlighter_is_translucent_in_export() {
|
||
// Schwarze Linie, darüber ein gelber Textmarker, der hin und zurück läuft
|
||
let mut page = page_with_line();
|
||
page.strokes[0].color = [0.0, 0.0, 0.0, 1.0];
|
||
for v in &mut page.strokes[0].vertices {
|
||
v.color = [0.0, 0.0, 0.0, 1.0];
|
||
}
|
||
let color = [1.0, 0.95, 0.1, 0.35];
|
||
let samples: Vec<StylusSample> = (0..=40)
|
||
.map(|i| {
|
||
let x = if i <= 20 { i } else { 40 - i } as f32;
|
||
StylusSample {
|
||
position: glam::Vec2::new(500.0 + x * 20.0, 1000.0 + (i as f32 * 0.5)),
|
||
pressure: 0.6,
|
||
timestamp: i as f64 * 0.01,
|
||
}
|
||
})
|
||
.collect();
|
||
let vertices = crate::storage::reconstruct_stroke_vertices(
|
||
&samples,
|
||
color,
|
||
60.0,
|
||
Default::default(),
|
||
true,
|
||
);
|
||
let mut stroke = Stroke::new(8, samples, vertices, color);
|
||
stroke.width = 60.0;
|
||
stroke.tool = crate::document::StrokeTool::Highlighter;
|
||
page.strokes.push(stroke);
|
||
|
||
let png = decode(
|
||
&page_png(&page, None, ImageBackground::Paper, PatternColor::default()).unwrap(),
|
||
);
|
||
let px_per_unit = png.width() as f32 / PAGE_WIDTH;
|
||
let at = |x: f32, y: f32| {
|
||
png.get_pixel((x * px_per_unit) as u32, (y * px_per_unit) as u32)
|
||
.0
|
||
};
|
||
// Die schwarze Linie bleibt unter dem Textmarker sichtbar
|
||
let line = at(700.0, 1000.0);
|
||
assert!(line[0] < 110 && line[1] < 110, "Linie verdeckt: {line:?}");
|
||
// Neben der Linie: einmal gelb getönt, auch wo der Strich sich selbst überlappt
|
||
let tint = at(700.0, 1020.0);
|
||
assert!(tint[2] < 200 && tint[0] > 200, "kein Textmarker: {tint:?}");
|
||
let expected_blue = 255.0 * (1.0 - 0.35) + 0.1 * 255.0 * 0.35;
|
||
assert!(
|
||
(tint[2] as f32 - expected_blue).abs() < 25.0,
|
||
"Textmarker mehrfach gedeckt: {tint:?}, erwartet Blau ≈ {expected_blue}"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn test_export_uses_pattern_color() {
|
||
// Kariertes Blatt: das kräftige Muster ist im Export dunkler als das helle
|
||
let page = Page::new(1, PageBackground::Grid5mm);
|
||
let darkest = |pattern| {
|
||
let png = decode(&page_png(&page, None, ImageBackground::Paper, pattern).unwrap());
|
||
png.pixels().map(|p| p.0[1]).min().unwrap()
|
||
};
|
||
let light = darkest(PatternColor::Light);
|
||
let strong = darkest(PatternColor::Strong);
|
||
assert!(strong + 60 < light, "hell {light}, kräftig {strong}");
|
||
}
|
||
|
||
#[test]
|
||
fn test_page_svg_contains_ink() {
|
||
let svg = page_svg(&page_with_line(), None, PatternColor::default()).unwrap();
|
||
assert!(svg.starts_with("<svg") || svg.contains("<svg"));
|
||
assert!(svg.contains("<path"));
|
||
assert!(svg.trim_end().ends_with("</svg>"));
|
||
}
|
||
|
||
/// Begrenzungsrechteck (x0, y0, x1, y1) der deckenden Pixel.
|
||
fn ink_bounds(width: u32, pixels: &[u8]) -> (u32, u32, u32, u32) {
|
||
let mut b = (u32::MAX, u32::MAX, 0, 0);
|
||
for (i, p) in pixels.as_chunks::<4>().0.iter().enumerate() {
|
||
if p[3] > 128 {
|
||
let (x, y) = (i as u32 % width, i as u32 / width);
|
||
b = (b.0.min(x), b.1.min(y), b.2.max(x), b.3.max(y));
|
||
}
|
||
}
|
||
b
|
||
}
|
||
|
||
fn page_with_text() -> (Page, crate::text::TextBox) {
|
||
let text = crate::text::TextBox::new(
|
||
"Hallo Welt\nzweite Zeile".into(),
|
||
100.0,
|
||
crate::text::TextFont::Sans,
|
||
1200.0,
|
||
);
|
||
let size = glam::Vec2::new(1200.0, text.layout().height());
|
||
let corners = crate::text::frame_corners(
|
||
glam::Vec2::new(300.0, 400.0),
|
||
[glam::Vec2::X, glam::Vec2::Y],
|
||
size,
|
||
);
|
||
let mut page = Page::new(1, PageBackground::Grid5mm);
|
||
page.strokes.push(Stroke::new_text(
|
||
3,
|
||
text.clone(),
|
||
corners,
|
||
[0.0, 0.0, 0.0, 1.0],
|
||
));
|
||
(page, text)
|
||
}
|
||
|
||
#[test]
|
||
fn test_exported_text_matches_screen_rendering() {
|
||
let (page, text) = page_with_text();
|
||
let png = decode(
|
||
&page_png(
|
||
&page,
|
||
None,
|
||
ImageBackground::Transparent,
|
||
PatternColor::default(),
|
||
)
|
||
.unwrap(),
|
||
);
|
||
let px_per_unit = png.width() as f32 / PAGE_WIDTH;
|
||
let exported = ink_bounds(png.width(), png.as_raw());
|
||
|
||
// Dieselbe Stelle, wie sie die Bildschirmtextur zeigt
|
||
let screen = crate::text::rasterize(&text, [0.0, 0.0, 0.0, 1.0], px_per_unit);
|
||
let local = ink_bounds(screen.width, &screen.pixels);
|
||
let (ox, oy) = ((300.0 * px_per_unit) as u32, (400.0 * px_per_unit) as u32);
|
||
let expected = (local.0 + ox, local.1 + oy, local.2 + ox, local.3 + oy);
|
||
for (a, b) in [
|
||
(exported.0, expected.0),
|
||
(exported.1, expected.1),
|
||
(exported.2, expected.2),
|
||
(exported.3, expected.3),
|
||
] {
|
||
assert!(
|
||
a.abs_diff(b) <= 3,
|
||
"PDF {exported:?} ≠ Bildschirm {expected:?}"
|
||
);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn test_exported_text_follows_rotation() {
|
||
let (mut page, _) = page_with_text();
|
||
let png = decode(
|
||
&page_png(
|
||
&page,
|
||
None,
|
||
ImageBackground::Transparent,
|
||
PatternColor::default(),
|
||
)
|
||
.unwrap(),
|
||
);
|
||
let straight = ink_bounds(png.width(), png.as_raw());
|
||
|
||
let center = page.strokes[0].aabb.min;
|
||
page.strokes[0].rotate(center, std::f32::consts::FRAC_PI_2);
|
||
let png = decode(
|
||
&page_png(
|
||
&page,
|
||
None,
|
||
ImageBackground::Transparent,
|
||
PatternColor::default(),
|
||
)
|
||
.unwrap(),
|
||
);
|
||
let rotated = ink_bounds(png.width(), png.as_raw());
|
||
// 90° im Uhrzeigersinn: Breite und Höhe tauschen, Text liegt links vom Drehpunkt
|
||
let (w, h) = (straight.2 - straight.0, straight.3 - straight.1);
|
||
let (rw, rh) = (rotated.2 - rotated.0, rotated.3 - rotated.1);
|
||
assert!(
|
||
rw.abs_diff(h) <= 4 && rh.abs_diff(w) <= 4,
|
||
"{straight:?} → {rotated:?}"
|
||
);
|
||
let px_per_unit = png.width() as f32 / PAGE_WIDTH;
|
||
assert!((rotated.2 as f32) < 300.0 * px_per_unit + 2.0);
|
||
}
|
||
|
||
#[test]
|
||
fn test_landscape_page_export_keeps_positions() {
|
||
// Strich unten rechts auf einer Querformat-Seite: im PNG an derselben Stelle
|
||
let mut page = page_with_line();
|
||
page.size = glam::Vec2::new(2970.0, 1671.0);
|
||
page.strokes[0].translate(glam::Vec2::new(1900.0, 500.0)); // x 2400–2800, y 1500
|
||
let png = decode(
|
||
&page_png(
|
||
&page,
|
||
None,
|
||
ImageBackground::Transparent,
|
||
PatternColor::default(),
|
||
)
|
||
.unwrap(),
|
||
);
|
||
let ratio = png.width() as f32 / png.height() as f32;
|
||
assert!((ratio - 2970.0 / 1671.0).abs() < 0.01, "{ratio}");
|
||
let px_per_unit = png.width() as f32 / 2970.0;
|
||
let at = |x: f32, y: f32| {
|
||
png.get_pixel((x * px_per_unit) as u32, (y * px_per_unit) as u32)
|
||
.0
|
||
};
|
||
assert!(at(2600.0, 1500.0)[3] > 200, "Strich liegt unten rechts");
|
||
assert!(at(2600.0, 1300.0)[3] == 0);
|
||
let svg = page_svg(&page, None, PatternColor::default()).unwrap();
|
||
assert!(svg.contains("<path"));
|
||
}
|
||
|
||
#[test]
|
||
fn test_selection_image_is_cropped() {
|
||
let page = page_with_line();
|
||
let image = selection_image(&page, &[7], None).unwrap().unwrap();
|
||
// Linie 400 Einheiten (40 mm) lang: Bild etwas breiter als 40 mm bei 150 dpi
|
||
let expected = 40.0 / 25.4 * SELECTION_PNG_DPI;
|
||
assert!(
|
||
(image.width as f32) > expected && (image.width as f32) < expected * 1.25,
|
||
"{} px",
|
||
image.width
|
||
);
|
||
assert!(image.height < image.width / 3);
|
||
assert!(image.pixels.as_chunks::<4>().0.iter().any(|p| p[3] > 200));
|
||
assert!(selection_image(&page, &[99], None).unwrap().is_none());
|
||
}
|
||
}
|