feat: Stifte, Seiten löschen, lückenlose Seiten, sauberer Textmarker (Issues #11, #12, #14, #15, #17, #19)
- #15: Koordinatensystem ohne Kerbe am Ursprung (Achsen beginnen eine halbe Linienbreite hinter dem Ursprung; im PDF ein Linienzug mit spitzer Ecke) - #17: Jeder Strich deckt ein Pixel nur einmal (Stencil-Puffer), der Textmarker wird innerhalb eines Strichs nicht dunkler. Im PDF-Export ist der Textmarker jetzt halbtransparent statt deckend. - #11: ➕ in der Werkzeugleiste fügt nach der aktuellen Seite eine Seite ein - #12: Seite löschen im ☰-Menü mit Rückfrage, in der Sitzung wiederherstellbar - #14: Seiten liegen lückenlos untereinander, eine dunkle Linie markiert die Grenze - #19: Vier Stifte mit eigener Farbe und Stärke, die sich Änderungen merken (ui.json); Blätterpfeile nur noch in der Einzelseitenansicht Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
904b36e5f7
commit
3890b6f182
14 files changed
+844
-80
No files matched your search
+104
-13
@@ -180,7 +180,7 @@ pub fn export_notebook_to_bytes(
|
||||
pattern: PatternColor,
|
||||
) -> Result<Vec<u8>, Box<dyn Error>> {
|
||||
let doc = generate_pdf_document(notebook, strategy, assets, pattern)?;
|
||||
let bytes = fix_text_encoding(doc.save_to_bytes()?, notebook)?;
|
||||
let bytes = add_alpha_states(fix_text_encoding(doc.save_to_bytes()?, notebook)?, notebook)?;
|
||||
|
||||
// Importierte PDF-Seiten als Vektorgrafik unterlegen
|
||||
let underlays = pdf_underlays(notebook);
|
||||
@@ -206,7 +206,7 @@ pub fn export_notebook_to_bytes(
|
||||
raster.insert(underlay.target_page);
|
||||
}
|
||||
let doc = generate_pdf_document_with(notebook, strategy, assets, pattern, &raster)?;
|
||||
let bytes = fix_text_encoding(doc.save_to_bytes()?, notebook)?;
|
||||
let bytes = add_alpha_states(fix_text_encoding(doc.save_to_bytes()?, notebook)?, notebook)?;
|
||||
let remaining: Vec<_> = underlays
|
||||
.into_iter()
|
||||
.filter(|u| !raster.contains(&u.target_page))
|
||||
@@ -247,6 +247,77 @@ fn fix_text_encoding(bytes: Vec<u8>, notebook: &Notebook) -> Result<Vec<u8>, Box
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Name des Grafikzustands für halbtransparente Tinte mit Deckkraft `alpha` (auf Prozent
|
||||
/// gerundet); `None` für deckende Farben.
|
||||
fn alpha_state_name(alpha: f32) -> Option<String> {
|
||||
let percent = (alpha.clamp(0.0, 1.0) * 100.0).round() as u32;
|
||||
(percent < 100).then(|| format!("InkAlpha{percent}"))
|
||||
}
|
||||
|
||||
/// printpdf kann keine Grafikzustände mit Deckkraft anlegen: Die Tintenebene verweist mit
|
||||
/// `gs` auf die Namen aus [`alpha_state_name`], die Zustände selbst (`ca`/`CA`) werden hier
|
||||
/// in die Ressourcen jeder Seite eingetragen. Ohne halbtransparente Striche bleibt das PDF
|
||||
/// unverändert.
|
||||
fn add_alpha_states(bytes: Vec<u8>, notebook: &Notebook) -> Result<Vec<u8>, Box<dyn Error>> {
|
||||
use printpdf::lopdf::{Dictionary, Object};
|
||||
|
||||
let mut states: Vec<(String, f32)> = notebook
|
||||
.pages
|
||||
.iter()
|
||||
.flat_map(|p| &p.strokes)
|
||||
.filter(|s| !s.is_object())
|
||||
.filter_map(|s| alpha_state_name(s.color[3]).map(|name| (name, s.color[3])))
|
||||
.collect();
|
||||
if states.is_empty() {
|
||||
return Ok(bytes);
|
||||
}
|
||||
states.sort_by(|a, b| a.0.cmp(&b.0));
|
||||
states.dedup_by(|a, b| a.0 == b.0);
|
||||
|
||||
let mut doc = printpdf::lopdf::Document::load_mem(&bytes)?;
|
||||
let page_ids: Vec<_> = doc.get_pages().into_values().collect();
|
||||
for page_id in page_ids {
|
||||
let resources = dict_object_id(&mut doc, page_id, b"Resources")?;
|
||||
let ext_g_state = dict_object_id(&mut doc, resources, b"ExtGState")?;
|
||||
let dict = doc.get_object_mut(ext_g_state)?.as_dict_mut()?;
|
||||
for (name, alpha) in &states {
|
||||
let alpha = (alpha.clamp(0.0, 1.0) * 100.0).round() / 100.0;
|
||||
let mut state = Dictionary::new();
|
||||
state.set("Type", Object::Name(b"ExtGState".to_vec()));
|
||||
state.set("ca", Object::Real(alpha));
|
||||
state.set("CA", Object::Real(alpha));
|
||||
dict.set(name.as_bytes().to_vec(), Object::Dictionary(state));
|
||||
}
|
||||
}
|
||||
let mut out = Vec::new();
|
||||
doc.save_to(&mut out)?;
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Kennung des Wörterbuchs unter `key` im Objekt `owner`; ein eingebettetes oder fehlendes
|
||||
/// Wörterbuch wird dafür zu einem eigenen Objekt.
|
||||
fn dict_object_id(
|
||||
doc: &mut printpdf::lopdf::Document,
|
||||
owner: printpdf::lopdf::ObjectId,
|
||||
key: &[u8],
|
||||
) -> Result<printpdf::lopdf::ObjectId, Box<dyn Error>> {
|
||||
use printpdf::lopdf::{Dictionary, Object};
|
||||
|
||||
let entry = doc.get_object(owner)?.as_dict()?.get(key).ok().cloned();
|
||||
if let Some(Object::Reference(id)) = entry {
|
||||
return Ok(id);
|
||||
}
|
||||
let dict = match entry {
|
||||
Some(Object::Dictionary(dict)) => dict,
|
||||
_ => Dictionary::new(),
|
||||
};
|
||||
let id = doc.add_object(dict);
|
||||
doc.get_object_mut(owner)?
|
||||
.as_dict_mut()?
|
||||
.set(key.to_vec(), Object::Reference(id));
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
/// UTF-8-Text als PDF-Textstring in UTF-16BE mit BOM; `None` bei reinem ASCII oder
|
||||
/// ungültigem UTF-8.
|
||||
fn utf16be_text(raw: &[u8]) -> Option<Vec<u8>> {
|
||||
@@ -468,6 +539,17 @@ fn render_ink_layer(layer: &printpdf::PdfLayerReference, page: &Page, strategy:
|
||||
if stroke.is_object() {
|
||||
continue;
|
||||
}
|
||||
// Halbtransparente Tinte (Textmarker): Deckkraft über einen Grafikzustand, den
|
||||
// `add_alpha_states` anlegt. Jeder Strich ist ein einziger Pfad und deckt daher jede
|
||||
// Stelle nur einmal, auch wo er sich selbst überlappt.
|
||||
let alpha_state = alpha_state_name(stroke.color[3]);
|
||||
if let Some(name) = &alpha_state {
|
||||
layer.save_graphics_state();
|
||||
layer.add_operation(printpdf::lopdf::content::Operation::new(
|
||||
"gs",
|
||||
vec![printpdf::lopdf::Object::Name(name.as_bytes().to_vec())],
|
||||
));
|
||||
}
|
||||
if let Some(ref shape) = stroke.shape {
|
||||
render_recognized_shape(layer, shape, stroke.color);
|
||||
} else {
|
||||
@@ -480,6 +562,9 @@ fn render_ink_layer(layer: &printpdf::PdfLayerReference, page: &Page, strategy:
|
||||
}
|
||||
}
|
||||
}
|
||||
if alpha_state.is_some() {
|
||||
layer.restore_graphics_state();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -773,6 +858,10 @@ fn render_recognized_shape(
|
||||
let stroke_color = Color::Rgb(Rgb::new(color[0], color[1], color[2], None));
|
||||
layer.set_outline_color(stroke_color.clone());
|
||||
layer.set_outline_thickness(1.5); // ca. 0.5 mm Standard-Liniendicke
|
||||
// Wie auf dem Bildschirm: spitze Ecken und stumpfe Enden (der Grafikzustand kann noch
|
||||
// runde Verbindungen von einem vorherigen Freihandstrich enthalten)
|
||||
layer.set_line_cap_style(printpdf::LineCapStyle::Butt);
|
||||
layer.set_line_join_style(printpdf::LineJoinStyle::Miter);
|
||||
|
||||
match shape {
|
||||
RecognizedShape::Line { start, end } => {
|
||||
@@ -966,17 +1055,19 @@ fn render_recognized_shape(
|
||||
(*y_axis_end, *x_axis_end)
|
||||
};
|
||||
|
||||
let (ox, oy) = page_to_pdf_mm(origin.x, origin.y);
|
||||
for end in [y_line_end, x_line_end] {
|
||||
let (ex, ey) = page_to_pdf_mm(end.x, end.y);
|
||||
layer.add_line(Line {
|
||||
points: vec![
|
||||
(Point::new(Mm(ox), Mm(oy)), false),
|
||||
(Point::new(Mm(ex), Mm(ey)), false),
|
||||
],
|
||||
is_closed: false,
|
||||
});
|
||||
}
|
||||
// Ein einziger Linienzug, damit die Ecke am Ursprung als Linienverbindung
|
||||
// geschlossen gezeichnet wird statt als zwei stumpfe Enden
|
||||
let points = [y_line_end, *origin, x_line_end]
|
||||
.iter()
|
||||
.map(|p| {
|
||||
let (px, py) = page_to_pdf_mm(p.x, p.y);
|
||||
(Point::new(Mm(px), Mm(py)), false)
|
||||
})
|
||||
.collect();
|
||||
layer.add_line(Line {
|
||||
points,
|
||||
is_closed: false,
|
||||
});
|
||||
|
||||
// Pfeilspitzen als gefüllte Dreiecke
|
||||
if *with_arrows {
|
||||
|
||||
Reference in new issue
Block a user