Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
41ee32e1f7 | ||
|
|
397bf3bfe4 |
No files matched your search
Generated
+1
-1
@@ -1411,7 +1411,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ink-notes"
|
||||
version = "0.36.0"
|
||||
version = "0.37.0"
|
||||
dependencies = [
|
||||
"ab_glyph",
|
||||
"arboard",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "ink-notes"
|
||||
version = "0.36.0"
|
||||
version = "0.37.0"
|
||||
edition = "2021"
|
||||
description = "Notizbuch-App für Stift, Touch und Maus unter Linux"
|
||||
repository = "https://gitea.pansi.eu/harald/ink-notes"
|
||||
|
||||
+3
-1
@@ -11,7 +11,7 @@
|
||||
|
||||
---
|
||||
|
||||
ink-notes speichert Handschrift als Vektoren, erkennt auf Wunsch Formen, kann PDFs zum Beschriften sowie Notizbücher aus Xournal++ und Rnote importieren, Bilder und Textfelder einfügen und alles wieder als PDF, PNG oder SVG exportieren. Gespeichert wird automatisch im Hintergrund.
|
||||
ink-notes speichert Handschrift als Vektoren, erkennt auf Wunsch Formen, kann PDFs zum Beschriften sowie Notizbücher aus Xournal++, Rnote und Saber importieren, Bilder und Textfelder einfügen und alles wieder als PDF, PNG oder SVG exportieren. Gespeichert wird automatisch im Hintergrund.
|
||||
|
||||
Getestet wird vor allem unter Zorin OS 18 / Ubuntu 24.04 (Wayland und X11).
|
||||
|
||||
@@ -50,6 +50,7 @@ Die Oberfläche gibt es auf Deutsch und Englisch; ink-notes folgt der Systemspra
|
||||
- **Importieren** (☰-Menü oder Datei ins Fenster ziehen) – **PDF**: Jede PDF-Seite wird eine Notizbuchseite mit der PDF-Seite als Hintergrund. Hochformat-Seiten werden DIN A4, Querformat-Seiten (z. B. Folien) bekommen eine Querformat-Seite ohne leeren Streifen darunter. Beim Hineinzoomen werden PDF-Seiten schärfer nachgerendert.
|
||||
**Xournal++** (`.xopp`, auch `.xoj` aus Xournal): Striche mit Stiftdruck, Textmarker, Texte, Bilder, liniertes/kariertes/gepunktetes Papier und PDF-Hintergründe werden übernommen (das PDF wird am alten Ort und neben der Datei gesucht). LaTeX-Formeln werden übersprungen.
|
||||
**Rnote** (`.rnote`, ab Rnote 0.5.10): Striche mit Stiftdruck, Formen, Texte und Bilder; in Rnote importierte PDF-Seiten werden zu Bildern. Seiten bleiben Seiten, unendliche Dokumente werden ein endloses Notizbuch.
|
||||
**Saber** (`.sbn2` mit den Asset-Dateien `.sbn2.0`, `.sbn2.1`, … daneben, `.sba`-Archive, altes `.sbn`): Striche mit Stiftdruck, Textmarker, Formen, getippter Text, Bilder und PDF-Hintergründe.
|
||||
|
||||
### Seiten und Ansicht
|
||||
- **Hintergründe:** blanko, kariert (5 mm), liniert (7,5 mm), Punktraster (5 mm). Die **Farbe von Karos, Linien und Punkten** ist wählbar (hell, mittel, kräftig, blau, grün) und gilt auch im Export.
|
||||
@@ -174,6 +175,7 @@ src/
|
||||
├── import.rs # Import als neues Notizbuch (Gemeinsames)
|
||||
├── xopp.rs # Import von Xournal++-/Xournal-Notizbüchern
|
||||
├── rnote.rs # Import von Rnote-Notizbüchern
|
||||
├── saber.rs # Import von Saber-Notizbüchern
|
||||
├── document.rs # Datenmodell: Notizbuch, Seiten, Striche, Seitengrößen
|
||||
├── history.rs # Rückgängig/Wiederholen
|
||||
├── storage.rs # Laden/Speichern (atomar), Notizbücher, Papierkorb
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
|
||||
---
|
||||
|
||||
ink-notes stores handwriting as vectors, can recognize shapes, imports PDFs for annotation as well as Xournal++ and Rnote notebooks, inserts images and text boxes, and exports everything again as PDF, PNG or SVG. Saving happens automatically in the background.
|
||||
ink-notes stores handwriting as vectors, can recognize shapes, imports PDFs for annotation as well as Xournal++, Rnote and Saber notebooks, inserts images and text boxes, and exports everything again as PDF, PNG or SVG. Saving happens automatically in the background.
|
||||
|
||||
It is mainly tested on Zorin OS 18 / Ubuntu 24.04 (Wayland and X11).
|
||||
|
||||
@@ -50,6 +50,7 @@ The interface is available in English and German; ink-notes follows the system l
|
||||
- **Import** (☰ menu, or drag the file into the window) – **PDF**: Every PDF page becomes a notebook page with the PDF page as background. Portrait pages become A4; landscape pages (e.g. slides) get a landscape page without an empty strip below. When you zoom in, PDF pages are re-rendered more sharply.
|
||||
**Xournal++** (`.xopp`, also `.xoj` from Xournal): strokes with pen pressure, highlighters, texts, images, lined/squared/dotted paper and PDF backgrounds are taken over (the PDF is looked for at its old place and next to the file). LaTeX formulas are skipped.
|
||||
**Rnote** (`.rnote`, from Rnote 0.5.10): strokes with pen pressure, shapes, texts and images; PDF pages imported in Rnote become images. Pages stay pages, infinite documents become an endless notebook.
|
||||
**Saber** (`.sbn2` with its asset files `.sbn2.0`, `.sbn2.1`, … next to it, `.sba` archives, old `.sbn`): strokes with pen pressure, highlighters, shapes, typed text, images and PDF backgrounds.
|
||||
|
||||
### Pages and view
|
||||
- **Backgrounds:** blank, squared (5 mm), ruled (7.5 mm), dot grid (5 mm). The **color of squares, lines and dots** is selectable (light, medium, strong, blue, green) and also applies to exports.
|
||||
@@ -176,6 +177,7 @@ src/
|
||||
├── import.rs # Import as a new notebook (shared parts)
|
||||
├── xopp.rs # Import of Xournal++/Xournal notebooks
|
||||
├── rnote.rs # Import of Rnote notebooks
|
||||
├── saber.rs # Import of Saber notebooks
|
||||
├── document.rs # Data model: notebook, pages, strokes, page sizes
|
||||
├── history.rs # Undo/redo
|
||||
├── storage.rs # Loading/saving (atomic), notebooks, trash
|
||||
|
||||
@@ -84,6 +84,11 @@
|
||||
</branding>
|
||||
<content_rating type="oars-1.1" />
|
||||
<releases>
|
||||
<release version="0.37.0" date="2026-10-27">
|
||||
<description>
|
||||
<p>Saber notebooks (.sbn2, .sba, .sbn) can be imported as new notebooks, with strokes, pen pressure, highlighters, shapes, typed text, images and PDF backgrounds.</p>
|
||||
</description>
|
||||
</release>
|
||||
<release version="0.36.0" date="2026-10-27">
|
||||
<description>
|
||||
<p>Notebooks from Xournal++ (.xopp, .xoj) and Rnote (.rnote) can be imported as new notebooks, with strokes, pen pressure, highlighters, shapes, texts, images and backgrounds.</p>
|
||||
|
||||
@@ -84,6 +84,11 @@
|
||||
</branding>
|
||||
<content_rating type="oars-1.1" />
|
||||
<releases>
|
||||
<release version="0.37.0" date="2026-10-27">
|
||||
<description>
|
||||
<p>Saber notebooks (.sbn2, .sba, .sbn) can be imported as new notebooks, with strokes, pen pressure, highlighters, shapes, typed text, images and PDF backgrounds.</p>
|
||||
</description>
|
||||
</release>
|
||||
<release version="0.36.0" date="2026-10-27">
|
||||
<description>
|
||||
<p>Notebooks from Xournal++ (.xopp, .xoj) and Rnote (.rnote) can be imported as new notebooks, with strokes, pen pressure, highlighters, shapes, texts, images and backgrounds.</p>
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
Notebook app for pen, touch and mouse on Linux (tested on Zorin OS 18 / Ubuntu 24.04).
|
||||
|
||||
### Highlights:
|
||||
- **Import from Saber**: ☰ → Import (or dragging the file into the window) now also opens Saber notebooks (`.sbn2` with its asset files next to it, `.sba` archives, old `.sbn`) – strokes with pen pressure, highlighters, shapes, typed text, images and PDF backgrounds
|
||||
- **Import from Xournal++ and Rnote**: ☰ → Import (or dragging the file into the window) now also opens Xournal++ (`.xopp`, `.xoj`) and Rnote (`.rnote`) notebooks as new notebooks – strokes with pen pressure, highlighters, shapes, texts, images, paper patterns and Xournal++ PDF backgrounds; PDF pages imported in Rnote become images, infinite Rnote documents an endless notebook
|
||||
- **Faster export**: PDF and PNG export and copying a selection as an image are about six times faster and PDFs are up to eight times smaller, because stroke outlines are simplified before export (at most 0.01 mm off; `INK_NOTES_EXACT_OUTLINES=1` exports every point as before)
|
||||
- **Fling timing**: The swipe speed for gliding is measured with the touch screen's own event times (Wayland), so a fast swipe glides even when the lift is handled late; the fling diagnostics also report slow event handling
|
||||
|
||||
+1
-1
@@ -2216,7 +2216,7 @@ pub fn run() -> Result<(), Box<dyn std::error::Error>> {
|
||||
}
|
||||
}
|
||||
|
||||
// Bilddatei ins Fenster gezogen: an der Ablagestelle einfügen; PDF/Xournal++/Rnote: importieren
|
||||
// Bilddatei ins Fenster gezogen: an der Ablagestelle einfügen; PDF/Xournal++/Rnote/Saber: importieren
|
||||
WindowEvent::DroppedFile(path) => {
|
||||
if crate::import::is_importable_path(&path) {
|
||||
ui_state.queued_actions.push(UiAction::ImportFile(path));
|
||||
|
||||
+6
-3
@@ -1,4 +1,4 @@
|
||||
//! Eingebaute, stift- und fingertaugliche Dateiauswahl für Bilder und importierbare Dateien (PDF, Xournal++, Rnote).
|
||||
//! Eingebaute, stift- und fingertaugliche Dateiauswahl für Bilder und importierbare Dateien (PDF, Xournal++, Rnote, Saber).
|
||||
//!
|
||||
//! Bewusst ohne Systemdialog: der bräuchte GTK oder das Desktop-Portal, die unter WSL und auf
|
||||
//! schlanken Systemen oft fehlen. Die Auswahl zeigt Ordner und die passenden Dateien des
|
||||
@@ -22,7 +22,7 @@ pub struct PickerEntry {
|
||||
pub enum PickerKind {
|
||||
/// Bilder zum Einfügen auf der aktuellen Seite
|
||||
Image,
|
||||
/// PDF, Xournal++ or Rnote file to import as a new notebook
|
||||
/// PDF, Xournal++, Rnote or Saber file to import as a new notebook
|
||||
Import,
|
||||
}
|
||||
|
||||
@@ -194,6 +194,8 @@ mod tests {
|
||||
"Skript.PDF",
|
||||
"mathe.xopp",
|
||||
"skizze.rnote",
|
||||
"Uni.sbn2",
|
||||
"Uni.sbn2.0",
|
||||
] {
|
||||
std::fs::write(dir.path().join(name), b"x").unwrap();
|
||||
}
|
||||
@@ -219,7 +221,8 @@ mod tests {
|
||||
"zOrdner",
|
||||
"mathe.xopp",
|
||||
"skizze.rnote",
|
||||
"Skript.PDF"
|
||||
"Skript.PDF",
|
||||
"Uni.sbn2"
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
@@ -169,6 +169,7 @@ mod tests {
|
||||
("updater.rs", include_str!("updater.rs")),
|
||||
("import.rs", include_str!("import.rs")),
|
||||
("rnote.rs", include_str!("rnote.rs")),
|
||||
("saber.rs", include_str!("saber.rs")),
|
||||
("xopp.rs", include_str!("xopp.rs")),
|
||||
];
|
||||
|
||||
|
||||
+6
-4
@@ -456,10 +456,12 @@ pub const EN: &[(&str, &str)] = &[
|
||||
("Datei nicht lesbar: {err}", "File not readable: {err}"),
|
||||
("Die Datei enthält keine Seiten", "The file contains no pages"),
|
||||
("Keine Xournal-Datei", "Not a Xournal file"),
|
||||
("Keine Ordner, PDF-, Xournal++- oder Rnote-Dateien", "No folders, PDF, Xournal++ or Rnote files"),
|
||||
("Die Datei wird als neues Notizbuch angelegt. PDF: eine Seite je PDF-Seite, beim Export liegen deine Notizen über den Originalseiten. Xournal++ (.xopp, .xoj) und Rnote (.rnote): Striche, Formen, Texte und Bilder werden übernommen, bei Xournal++ auch PDF-Hintergründe.", "The file becomes a new notebook. PDF: one page per PDF page, when exporting your notes lie on top of the original pages. Xournal++ (.xopp, .xoj) and Rnote (.rnote): strokes, shapes, texts and images are taken over, for Xournal++ also PDF backgrounds."),
|
||||
("Vorlesungsfolien, Übungsblätter, Formulare oder Notizbücher aus Xournal++ und Rnote als neues Notizbuch öffnen und darauf schreiben", "Open lecture slides, worksheets, forms or notebooks from Xournal++ and Rnote as a new notebook and write on them"),
|
||||
("📄 Importieren (PDF, Xournal++, Rnote) …", "📄 Import (PDF, Xournal++, Rnote) …"),
|
||||
("Keine Rnote-Datei", "Not an Rnote file"),
|
||||
("Rnote-Datei zu alt ({version}): bitte in einem aktuellen Rnote öffnen und neu speichern", "Rnote file too old ({version}): please open it in a current Rnote and save it again"),
|
||||
("Keine Ordner oder importierbaren Dateien (PDF, Xournal++, Rnote, Saber)", "No folders or importable files (PDF, Xournal++, Rnote, Saber)"),
|
||||
("Die Datei wird als neues Notizbuch angelegt. PDF: eine Seite je PDF-Seite, beim Export liegen deine Notizen über den Originalseiten. Xournal++ (.xopp, .xoj), Rnote (.rnote) und Saber (.sbn2, .sba): Striche, Formen, Texte und Bilder werden übernommen, bei Xournal++ und Saber auch PDF-Hintergründe.", "The file becomes a new notebook. PDF: one page per PDF page, when exporting your notes lie on top of the original pages. Xournal++ (.xopp, .xoj), Rnote (.rnote) and Saber (.sbn2, .sba): strokes, shapes, texts and images are taken over, for Xournal++ and Saber also PDF backgrounds."),
|
||||
("Vorlesungsfolien, Übungsblätter, Formulare oder Notizbücher aus Xournal++, Rnote und Saber als neues Notizbuch öffnen und darauf schreiben", "Open lecture slides, worksheets, forms or notebooks from Xournal++, Rnote and Saber as a new notebook and write on them"),
|
||||
("📄 Importieren (PDF, Xournal++, Rnote, Saber) …", "📄 Import (PDF, Xournal++, Rnote, Saber) …"),
|
||||
("Keine Saber-Datei", "Not a Saber file"),
|
||||
("Saber-Datei zu alt: bitte in einem aktuellen Saber öffnen und neu speichern", "Saber file too old: please open it in a current Saber and save it again"),
|
||||
];
|
||||
+37
-3
@@ -1,6 +1,6 @@
|
||||
//! Import of other formats as a new notebook (Issue #47): PDF (`storage`), Xournal++
|
||||
//! (`xopp`) and Rnote (`rnote`). This module holds what the importers share: building
|
||||
//! strokes, texts and images in the stored format and creating the notebook.
|
||||
//! (`xopp`), Rnote (`rnote`) and Saber (`saber`). This module holds what the importers
|
||||
//! share: building strokes, texts and images in the stored format and creating the notebook.
|
||||
|
||||
use std::io::Read;
|
||||
use std::path::{Path, PathBuf};
|
||||
@@ -37,9 +37,10 @@ pub fn is_importable_path(path: &Path) -> bool {
|
||||
crate::file_picker::is_pdf_path(path)
|
||||
|| crate::xopp::is_xopp_path(path)
|
||||
|| crate::rnote::is_rnote_path(path)
|
||||
|| crate::saber::is_saber_path(path)
|
||||
}
|
||||
|
||||
/// Imports a PDF, Xournal++ or Rnote file as a new notebook titled `title`.
|
||||
/// Imports a PDF, Xournal++, Rnote or Saber file as a new notebook titled `title`.
|
||||
pub fn import_file(
|
||||
base_dir: &Path,
|
||||
title: &str,
|
||||
@@ -49,6 +50,8 @@ pub fn import_file(
|
||||
crate::xopp::create_notebook_from_xopp(base_dir, title, path)
|
||||
} else if crate::rnote::is_rnote_path(path) {
|
||||
crate::rnote::create_notebook_from_rnote(base_dir, title, path)
|
||||
} else if crate::saber::is_saber_path(path) {
|
||||
crate::saber::create_notebook_from_saber(base_dir, title, path)
|
||||
} else {
|
||||
let bytes = std::fs::read(path).map_err(|e| e.to_string())?;
|
||||
storage::create_notebook_from_pdf(base_dir, title, bytes)
|
||||
@@ -228,6 +231,37 @@ pub fn image_stroke(id: u64, asset: String, corners: [glam::Vec2; 4]) -> StrokeD
|
||||
}
|
||||
}
|
||||
|
||||
/// Premultiplied RGBA (Rnote, tiny-skia) to straight RGBA.
|
||||
pub fn unpremultiply(pixels: &mut [u8]) {
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Rasterizes an SVG for a box of `size` page units (about 250 dpi).
|
||||
pub fn rasterize_svg(svg: &str, size: glam::Vec2) -> Option<(u32, u32, Vec<u8>)> {
|
||||
use resvg::{tiny_skia, usvg};
|
||||
let tree = usvg::Tree::from_str(svg, &usvg::Options::default()).ok()?;
|
||||
let max = crate::assets::MAX_IMPORT_DIMENSION as f32;
|
||||
let px_per_unit = 250.0 / 254.0;
|
||||
let width = (size.x * px_per_unit).round().clamp(1.0, max) as u32;
|
||||
let height = (size.y * px_per_unit).round().clamp(1.0, max) as u32;
|
||||
let mut pixmap = tiny_skia::Pixmap::new(width, height)?;
|
||||
let transform = tiny_skia::Transform::from_scale(
|
||||
width as f32 / tree.size().width(),
|
||||
height as f32 / tree.size().height(),
|
||||
);
|
||||
resvg::render(&tree, transform, &mut pixmap.as_mut());
|
||||
let mut pixels = pixmap.take();
|
||||
unpremultiply(&mut pixels);
|
||||
Some((width, height, pixels))
|
||||
}
|
||||
|
||||
fn corner_samples(corners: [glam::Vec2; 4]) -> Vec<SampleData> {
|
||||
corners
|
||||
.iter()
|
||||
|
||||
@@ -22,6 +22,7 @@ pub mod pdf_merge;
|
||||
pub mod platform;
|
||||
pub mod rnote;
|
||||
pub mod ruler;
|
||||
pub mod saber;
|
||||
pub mod selection;
|
||||
pub mod settings;
|
||||
pub mod spline;
|
||||
|
||||
+2
-33
@@ -380,7 +380,7 @@ fn bitmapimage(value: &Value) -> Option<RnoteItem> {
|
||||
if pixels.len() != width as usize * height as usize * 4 {
|
||||
return None;
|
||||
}
|
||||
unpremultiply(&mut pixels);
|
||||
import::unpremultiply(&mut pixels);
|
||||
Some(RnoteItem::Bitmap {
|
||||
width,
|
||||
height,
|
||||
@@ -407,37 +407,6 @@ fn rectangle_corners(rectangle: &Value) -> Option<[DVec2; 4]> {
|
||||
)
|
||||
}
|
||||
|
||||
/// Premultiplied RGBA (Rnote, tiny-skia) to straight RGBA.
|
||||
fn unpremultiply(pixels: &mut [u8]) {
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Rasterizes an SVG for a box of `size` page units (about 250 dpi).
|
||||
fn rasterize_svg(svg: &str, size: glam::Vec2) -> Option<(u32, u32, Vec<u8>)> {
|
||||
use resvg::{tiny_skia, usvg};
|
||||
let tree = usvg::Tree::from_str(svg, &usvg::Options::default()).ok()?;
|
||||
let max = crate::assets::MAX_IMPORT_DIMENSION as f32;
|
||||
let px_per_unit = 250.0 / 254.0;
|
||||
let width = (size.x * px_per_unit).round().clamp(1.0, max) as u32;
|
||||
let height = (size.y * px_per_unit).round().clamp(1.0, max) as u32;
|
||||
let mut pixmap = tiny_skia::Pixmap::new(width, height)?;
|
||||
let transform = tiny_skia::Transform::from_scale(
|
||||
width as f32 / tree.size().width(),
|
||||
height as f32 / tree.size().height(),
|
||||
);
|
||||
resvg::render(&tree, transform, &mut pixmap.as_mut());
|
||||
let mut pixels = pixmap.take();
|
||||
unpremultiply(&mut pixels);
|
||||
Some((width, height, pixels))
|
||||
}
|
||||
|
||||
/// Rnote's version as (major, minor, patch).
|
||||
fn version(text: &str) -> Option<(u64, u64, u64)> {
|
||||
let v = semver::Version::parse(text).ok()?;
|
||||
@@ -680,7 +649,7 @@ fn convert(doc: &RnoteDocument, notebook_dir: &Path) -> Imported {
|
||||
corners[0].distance(corners[1]),
|
||||
corners[0].distance(corners[3]),
|
||||
);
|
||||
let image = rasterize_svg(svg, size).and_then(|(width, height, pixels)| {
|
||||
let image = import::rasterize_svg(svg, size).and_then(|(width, height, pixels)| {
|
||||
assets.import_rgba(width, height, pixels).ok()
|
||||
});
|
||||
match image {
|
||||
|
||||
+904
@@ -0,0 +1,904 @@
|
||||
//! Import of Saber notebooks (.sbn2, .sba, old .sbn) (Issue #47).
|
||||
//!
|
||||
//! A `.sbn2` file is a BSON document; images and PDFs lie next to it as `<file>.sbn2.0`,
|
||||
//! `<file>.sbn2.1`, …; a `.sba` archive is a zip with `main.sbn2` and its assets, and the old
|
||||
//! `.sbn` is the same document as JSON. Pages (`z`) hold strokes (`s`), images (`i`), typed
|
||||
//! text as a Quill delta (`q`) and an optional background image or PDF page (`b`). Points are
|
||||
//! three little-endian `f32` (x, y, pressure) since format version 13.
|
||||
//!
|
||||
//! Stroke widths follow perfect_freehand: the diameter is `size · (1 − thinning · (1 − 2p))`.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::io::Read;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use crate::document::{Notebook, PageBackground, PdfBackground};
|
||||
use crate::import::{self, ImportReport, Imported};
|
||||
use crate::storage::{NotebookManifest, StrokeData};
|
||||
use crate::t;
|
||||
use crate::text::TextFont;
|
||||
|
||||
/// Saber's default page size, used when a page has none.
|
||||
const DEFAULT_PAGE: glam::Vec2 = glam::Vec2::new(1000.0, 1400.0);
|
||||
|
||||
/// Is the file by its name a Saber notebook?
|
||||
pub fn is_saber_path(path: &Path) -> bool {
|
||||
path.extension().and_then(|e| e.to_str()).is_some_and(|e| {
|
||||
["sbn2", "sba", "sbn"]
|
||||
.iter()
|
||||
.any(|x| e.eq_ignore_ascii_case(x))
|
||||
})
|
||||
}
|
||||
|
||||
/// A BSON (or JSON) value as far as Saber uses it.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum Bson {
|
||||
Null,
|
||||
Bool(bool),
|
||||
Int(i64),
|
||||
Double(f64),
|
||||
Str(String),
|
||||
Binary(Vec<u8>),
|
||||
Array(Vec<Bson>),
|
||||
Doc(Vec<(String, Bson)>),
|
||||
}
|
||||
|
||||
impl Bson {
|
||||
pub fn get(&self, key: &str) -> Option<&Bson> {
|
||||
match self {
|
||||
Bson::Doc(fields) => fields
|
||||
.iter()
|
||||
.find(|(k, _)| k == key)
|
||||
.map(|(_, v)| v)
|
||||
.filter(|v| **v != Bson::Null),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn as_f64(&self) -> Option<f64> {
|
||||
match *self {
|
||||
Bson::Double(v) if v.is_finite() => Some(v),
|
||||
Bson::Int(v) => Some(v as f64),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn as_i64(&self) -> Option<i64> {
|
||||
match *self {
|
||||
Bson::Int(v) => Some(v),
|
||||
Bson::Double(v) if v.is_finite() => Some(v as i64),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn as_str(&self) -> Option<&str> {
|
||||
match self {
|
||||
Bson::Str(s) => Some(s),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn as_array(&self) -> &[Bson] {
|
||||
match self {
|
||||
Bson::Array(items) => items,
|
||||
_ => &[],
|
||||
}
|
||||
}
|
||||
|
||||
fn num(&self, key: &str) -> Option<f64> {
|
||||
self.get(key)?.as_f64()
|
||||
}
|
||||
|
||||
fn bool(&self, key: &str) -> Option<bool> {
|
||||
match self.get(key)? {
|
||||
Bson::Bool(b) => Some(*b),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn from_json(value: serde_json::Value) -> Bson {
|
||||
use serde_json::Value;
|
||||
match value {
|
||||
Value::Null => Bson::Null,
|
||||
Value::Bool(b) => Bson::Bool(b),
|
||||
Value::Number(n) => n
|
||||
.as_i64()
|
||||
.map(Bson::Int)
|
||||
.unwrap_or_else(|| Bson::Double(n.as_f64().unwrap_or(0.0))),
|
||||
Value::String(s) => Bson::Str(s),
|
||||
Value::Array(items) => Bson::Array(items.into_iter().map(Bson::from_json).collect()),
|
||||
Value::Object(map) => Bson::Doc(
|
||||
map.into_iter()
|
||||
.map(|(k, v)| (k, Bson::from_json(v)))
|
||||
.collect(),
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Reads a BSON document.
|
||||
pub fn parse_bson(bytes: &[u8]) -> Result<Bson, String> {
|
||||
let mut pos = 0;
|
||||
document(bytes, &mut pos, 0).ok_or_else(|| t!("Keine Saber-Datei").to_string())
|
||||
}
|
||||
|
||||
fn take<'a>(bytes: &'a [u8], pos: &mut usize, n: usize) -> Option<&'a [u8]> {
|
||||
let slice = bytes.get(*pos..pos.checked_add(n)?)?;
|
||||
*pos += n;
|
||||
Some(slice)
|
||||
}
|
||||
|
||||
fn int32(bytes: &[u8], pos: &mut usize) -> Option<i32> {
|
||||
Some(i32::from_le_bytes(take(bytes, pos, 4)?.try_into().ok()?))
|
||||
}
|
||||
|
||||
fn cstring(bytes: &[u8], pos: &mut usize) -> Option<String> {
|
||||
let len = bytes.get(*pos..)?.iter().position(|&b| b == 0)?;
|
||||
let s = String::from_utf8_lossy(take(bytes, pos, len)?).into_owned();
|
||||
*pos += 1;
|
||||
Some(s)
|
||||
}
|
||||
|
||||
/// A document (or array, which is a document with the keys "0", "1", …).
|
||||
fn document(bytes: &[u8], pos: &mut usize, depth: usize) -> Option<Bson> {
|
||||
if depth > 64 {
|
||||
return None;
|
||||
}
|
||||
let start = *pos;
|
||||
let len = usize::try_from(int32(bytes, pos)?).ok()?;
|
||||
let end = start.checked_add(len)?;
|
||||
if end > bytes.len() || len < 5 {
|
||||
return None;
|
||||
}
|
||||
let mut fields = Vec::new();
|
||||
loop {
|
||||
let kind = *bytes.get(*pos)?;
|
||||
*pos += 1;
|
||||
if kind == 0 {
|
||||
break;
|
||||
}
|
||||
let key = cstring(bytes, pos)?;
|
||||
let value = match kind {
|
||||
0x01 => Bson::Double(f64::from_le_bytes(take(bytes, pos, 8)?.try_into().ok()?)),
|
||||
0x02 => {
|
||||
let n = usize::try_from(int32(bytes, pos)?).ok()?;
|
||||
let s = take(bytes, pos, n)?;
|
||||
Bson::Str(String::from_utf8_lossy(s.strip_suffix(&[0]).unwrap_or(s)).into_owned())
|
||||
}
|
||||
0x03 => document(bytes, pos, depth + 1)?,
|
||||
0x04 => match document(bytes, pos, depth + 1)? {
|
||||
Bson::Doc(items) => Bson::Array(items.into_iter().map(|(_, v)| v).collect()),
|
||||
other => other,
|
||||
},
|
||||
0x05 => {
|
||||
let n = usize::try_from(int32(bytes, pos)?).ok()?;
|
||||
*pos += 1; // subtype
|
||||
Bson::Binary(take(bytes, pos, n)?.to_vec())
|
||||
}
|
||||
0x07 => {
|
||||
take(bytes, pos, 12)?;
|
||||
Bson::Null
|
||||
}
|
||||
0x08 => Bson::Bool(*take(bytes, pos, 1)?.first()? != 0),
|
||||
0x09 | 0x11 | 0x12 => {
|
||||
Bson::Int(i64::from_le_bytes(take(bytes, pos, 8)?.try_into().ok()?))
|
||||
}
|
||||
0x0A | 0x06 | 0x7F | 0xFF => Bson::Null,
|
||||
0x10 => Bson::Int(int32(bytes, pos)? as i64),
|
||||
_ => return None,
|
||||
};
|
||||
fields.push((key, value));
|
||||
}
|
||||
(*pos == end).then_some(Bson::Doc(fields))
|
||||
}
|
||||
|
||||
/// Reads the files of a zip archive (stored or deflated), at most `limit` bytes in total.
|
||||
fn unzip(bytes: &[u8], limit: usize) -> Result<HashMap<String, Vec<u8>>, String> {
|
||||
let broken = || t!("Datei beschädigt: {err}", err = "zip").to_string();
|
||||
let u16_at = |at: usize| -> Option<usize> {
|
||||
Some(u16::from_le_bytes(bytes.get(at..at + 2)?.try_into().ok()?) as usize)
|
||||
};
|
||||
let u32_at = |at: usize| -> Option<usize> {
|
||||
Some(u32::from_le_bytes(bytes.get(at..at + 4)?.try_into().ok()?) as usize)
|
||||
};
|
||||
// End of central directory: the last record with its signature
|
||||
let eocd = (0..bytes.len().saturating_sub(21))
|
||||
.rev()
|
||||
.find(|&i| bytes[i..].starts_with(&[0x50, 0x4b, 0x05, 0x06]))
|
||||
.ok_or_else(broken)?;
|
||||
let count = u16_at(eocd + 10).ok_or_else(broken)?;
|
||||
let mut at = u32_at(eocd + 16).ok_or_else(broken)?;
|
||||
let mut files = HashMap::new();
|
||||
let mut total = 0;
|
||||
for _ in 0..count {
|
||||
let entry = (|| {
|
||||
if !bytes.get(at..)?.starts_with(&[0x50, 0x4b, 0x01, 0x02]) {
|
||||
return None;
|
||||
}
|
||||
let method = u16_at(at + 10)?;
|
||||
let compressed = u32_at(at + 20)?;
|
||||
let size = u32_at(at + 24)?;
|
||||
let name_len = u16_at(at + 28)?;
|
||||
let skip = name_len + u16_at(at + 30)? + u16_at(at + 32)?;
|
||||
let local = u32_at(at + 42)?;
|
||||
let name = String::from_utf8_lossy(bytes.get(at + 46..at + 46 + name_len)?);
|
||||
at += 46 + skip;
|
||||
let data_start = local + 30 + u16_at(local + 26)? + u16_at(local + 28)?;
|
||||
let data = bytes.get(data_start..data_start.checked_add(compressed)?)?;
|
||||
Some((name.into_owned(), method, size, data))
|
||||
})();
|
||||
let (name, method, size, data) = entry.ok_or_else(broken)?;
|
||||
total += size;
|
||||
if total > limit {
|
||||
return Err(broken());
|
||||
}
|
||||
let content = match method {
|
||||
0 => data.to_vec(),
|
||||
8 => {
|
||||
let mut out = Vec::with_capacity(size);
|
||||
flate2::read::DeflateDecoder::new(data)
|
||||
.take(size as u64)
|
||||
.read_to_end(&mut out)
|
||||
.map_err(|_| broken())?;
|
||||
out
|
||||
}
|
||||
_ => return Err(broken()),
|
||||
};
|
||||
files.insert(name, content);
|
||||
}
|
||||
Ok(files)
|
||||
}
|
||||
|
||||
/// The document and a way to get its assets (by index).
|
||||
struct Source {
|
||||
doc: Bson,
|
||||
assets: Box<dyn Fn(usize) -> Option<Vec<u8>>>,
|
||||
}
|
||||
|
||||
fn load(path: &Path) -> Result<Source, String> {
|
||||
let bytes = std::fs::read(path).map_err(|e| e.to_string())?;
|
||||
if bytes.starts_with(&[0x50, 0x4b, 0x03, 0x04]) {
|
||||
let files = unzip(&bytes, 1024 * 1024 * 1024)?;
|
||||
let main = files
|
||||
.iter()
|
||||
.find(|(name, _)| name.ends_with(".sbn2"))
|
||||
.map(|(name, data)| (name.clone(), data))
|
||||
.ok_or_else(|| t!("Keine Saber-Datei").to_string())?;
|
||||
let doc = parse_bson(main.1)?;
|
||||
let prefix = main.0;
|
||||
return Ok(Source {
|
||||
doc,
|
||||
assets: Box::new(move |i| files.get(&format!("{prefix}.{i}")).cloned()),
|
||||
});
|
||||
}
|
||||
// .sbn2 is BSON, the old .sbn JSON
|
||||
let doc = match parse_bson(&bytes) {
|
||||
Ok(doc) => doc,
|
||||
Err(err) => serde_json::from_slice(&bytes)
|
||||
.map(Bson::from_json)
|
||||
.map_err(|_| err)?,
|
||||
};
|
||||
let base = path.to_path_buf();
|
||||
Ok(Source {
|
||||
doc,
|
||||
assets: Box::new(move |i| {
|
||||
std::fs::read(PathBuf::from(format!("{}.{i}", base.display()))).ok()
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
/// Color stored as ARGB integer.
|
||||
fn argb(value: Option<&Bson>) -> [f32; 4] {
|
||||
let v = value.and_then(Bson::as_i64).unwrap_or(0xff000000) as u32;
|
||||
[
|
||||
((v >> 16) & 0xff) as f32 / 255.0,
|
||||
((v >> 8) & 0xff) as f32 / 255.0,
|
||||
(v & 0xff) as f32 / 255.0,
|
||||
(v >> 24) as f32 / 255.0,
|
||||
]
|
||||
}
|
||||
|
||||
/// Points of a stroke: `f32` triples (version 13 on) or `{x, y, p}` documents.
|
||||
fn points(stroke: &Bson) -> Vec<(glam::Vec2, Option<f32>)> {
|
||||
let offset = glam::Vec2::new(
|
||||
stroke.num("ox").unwrap_or(0.0) as f32,
|
||||
stroke.num("oy").unwrap_or(0.0) as f32,
|
||||
);
|
||||
let Some(list) = stroke.get("p") else {
|
||||
return Vec::new();
|
||||
};
|
||||
list.as_array()
|
||||
.iter()
|
||||
.filter_map(|point| match point {
|
||||
Bson::Binary(data) => {
|
||||
let values: Vec<f32> = data
|
||||
.as_chunks::<4>()
|
||||
.0
|
||||
.iter()
|
||||
.map(|b| f32::from_le_bytes(*b))
|
||||
.collect();
|
||||
match values[..] {
|
||||
[x, y] => Some((glam::Vec2::new(x, y), None)),
|
||||
[x, y, p, ..] => Some((glam::Vec2::new(x, y), Some(p))),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
doc @ Bson::Doc(_) => Some((
|
||||
glam::Vec2::new(doc.num("x")? as f32, doc.num("y")? as f32),
|
||||
doc.num("p").map(|p| p as f32),
|
||||
)),
|
||||
_ => None,
|
||||
})
|
||||
.filter(|(p, _)| p.is_finite())
|
||||
.map(|(p, pressure)| (p + offset, pressure))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Outline points of a shape stroke (circle or rectangle).
|
||||
fn shape_points(stroke: &Bson, shape: &str) -> Vec<glam::Vec2> {
|
||||
let n = |k| stroke.num(k).unwrap_or(0.0) as f32;
|
||||
match shape {
|
||||
"circle" => {
|
||||
let (center, r) = (glam::Vec2::new(n("cx"), n("cy")), n("r"));
|
||||
(0..=64)
|
||||
.map(|i| {
|
||||
let a = i as f32 / 64.0 * std::f32::consts::TAU;
|
||||
center + glam::Vec2::new(a.cos(), a.sin()) * r
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
"rect" => {
|
||||
let (l, t, w, h) = (n("rl"), n("rt"), n("rw"), n("rh"));
|
||||
[(l, t), (l + w, t), (l + w, t + h), (l, t + h), (l, t)]
|
||||
.map(|(x, y)| glam::Vec2::new(x, y))
|
||||
.to_vec()
|
||||
}
|
||||
_ => Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Typed text of a page: the inserted strings of its Quill delta.
|
||||
fn quill_text(delta: &Bson) -> String {
|
||||
let text: String = delta
|
||||
.as_array()
|
||||
.iter()
|
||||
.filter_map(|op| op.get("insert")?.as_str())
|
||||
.collect();
|
||||
text.trim_end().to_string()
|
||||
}
|
||||
|
||||
/// Creates a new notebook from a Saber file.
|
||||
pub fn create_notebook_from_saber(
|
||||
base_dir: &Path,
|
||||
title: &str,
|
||||
path: &Path,
|
||||
) -> Result<(Notebook, NotebookManifest, PathBuf, ImportReport), String> {
|
||||
let source = load(path)?;
|
||||
// The very first files were only a list of strokes; versions up to 7 kept strokes and
|
||||
// images outside the pages, which is read as well
|
||||
if !matches!(source.doc, Bson::Doc(_)) {
|
||||
return Err(t!(
|
||||
"Saber-Datei zu alt: bitte in einem aktuellen Saber öffnen und neu speichern"
|
||||
)
|
||||
.to_string());
|
||||
}
|
||||
import::create_notebook(base_dir, title, |dir| Ok(convert(&source, dir)))
|
||||
}
|
||||
|
||||
/// A PDF of the notebook in our asset folder.
|
||||
struct PdfSource {
|
||||
asset: String,
|
||||
sizes: Vec<(f32, f32)>,
|
||||
}
|
||||
|
||||
fn convert(source: &Source, notebook_dir: &Path) -> Imported {
|
||||
let doc = &source.doc;
|
||||
let assets = crate::assets::AssetStore::new(notebook_dir);
|
||||
let mut report = ImportReport::default();
|
||||
let mut pdfs: HashMap<usize, Option<PdfSource>> = HashMap::new();
|
||||
let background = match doc.get("p").and_then(Bson::as_str) {
|
||||
Some("lined" | "college" | "college-rtl" | "cornell") => PageBackground::Lined,
|
||||
Some("grid") => PageBackground::Grid5mm,
|
||||
Some("dots") => PageBackground::DotGrid,
|
||||
_ => PageBackground::Blank,
|
||||
};
|
||||
let line_height = doc.num("l").unwrap_or(40.0) as f32;
|
||||
let mut xpages: Vec<&Bson> = doc
|
||||
.get("z")
|
||||
.map(Bson::as_array)
|
||||
.unwrap_or_default()
|
||||
.iter()
|
||||
.collect();
|
||||
let empty = Bson::Doc(Vec::new());
|
||||
if xpages.is_empty() {
|
||||
xpages.push(&empty);
|
||||
}
|
||||
// Before version 8 strokes and images were stored outside the pages, with a page index
|
||||
let old_items = |key: &str, index: usize| -> Vec<&Bson> {
|
||||
doc.get(key)
|
||||
.map(Bson::as_array)
|
||||
.unwrap_or_default()
|
||||
.iter()
|
||||
.filter(|item| item.num("i").unwrap_or(0.0) as usize == index)
|
||||
.collect()
|
||||
};
|
||||
|
||||
let mut next_id: u64 = 1;
|
||||
let mut new_id = || {
|
||||
next_id += 1;
|
||||
next_id - 1
|
||||
};
|
||||
let mut pages = Vec::with_capacity(xpages.len());
|
||||
for (index, xpage) in xpages.iter().enumerate() {
|
||||
// Pages stored as [width, height] in very old files
|
||||
let page_size = match xpage {
|
||||
Bson::Array(wh) => glam::Vec2::new(
|
||||
wh.first().and_then(Bson::as_f64).unwrap_or(1000.0) as f32,
|
||||
wh.get(1).and_then(Bson::as_f64).unwrap_or(1400.0) as f32,
|
||||
),
|
||||
_ => glam::Vec2::new(
|
||||
xpage.num("w").map_or(DEFAULT_PAGE.x, |w| w as f32),
|
||||
xpage.num("h").map_or(DEFAULT_PAGE.y, |h| h as f32),
|
||||
),
|
||||
}
|
||||
.max(glam::Vec2::ONE);
|
||||
// Same rules as for imported PDFs: portrait pages become A4, the content keeps its
|
||||
// aspect ratio, centred horizontally and aligned at the top
|
||||
let size = crate::document::pdf_page_size(page_size.x, page_size.y);
|
||||
let placement = crate::document::pdf_placement(page_size.x, page_size.y, size);
|
||||
let scale = (placement[1].x - placement[0].x) / page_size.x;
|
||||
let map = |p: glam::Vec2| placement[0] + p * scale;
|
||||
|
||||
let mut strokes: Vec<StrokeData> = Vec::new();
|
||||
let mut pdf_background = None;
|
||||
|
||||
if let Some(image) = xpage.get("b") {
|
||||
let natural = glam::Vec2::new(
|
||||
image.num("nw").unwrap_or(0.0) as f32,
|
||||
image.num("nh").unwrap_or(0.0) as f32,
|
||||
);
|
||||
let corners = if natural.min_element() > 0.0 {
|
||||
crate::document::pdf_placement(natural.x, natural.y, size)
|
||||
} else {
|
||||
placement
|
||||
};
|
||||
let asset = image.num("a").map(|a| a as usize);
|
||||
if image.get("e").and_then(Bson::as_str) == Some(".pdf") {
|
||||
let pdf = asset.and_then(|a| {
|
||||
pdfs.entry(a)
|
||||
.or_insert_with(|| {
|
||||
let bytes = (source.assets)(a)?;
|
||||
let sizes = crate::pdf_doc::page_sizes(bytes.clone()).ok()?;
|
||||
let asset = assets.import_pdf(&bytes).ok()?;
|
||||
Some(PdfSource { asset, sizes })
|
||||
})
|
||||
.as_ref()
|
||||
});
|
||||
let page = image.num("pdfi").unwrap_or(0.0) as u32;
|
||||
match pdf.and_then(|pdf| Some((pdf, *pdf.sizes.get(page as usize)?))) {
|
||||
Some((pdf, (width_pt, height_pt))) => {
|
||||
pdf_background = Some(PdfBackground {
|
||||
asset: pdf.asset.clone(),
|
||||
page,
|
||||
width_pt,
|
||||
height_pt,
|
||||
})
|
||||
}
|
||||
None => report.skipped += 1,
|
||||
}
|
||||
} else {
|
||||
match image_asset(image, source, &assets, corners) {
|
||||
Some(asset) => strokes.push(import::image_stroke(new_id(), asset, corners)),
|
||||
None => report.skipped += 1,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let images = xpage.get("i").map(Bson::as_array).unwrap_or_default();
|
||||
for image in images.iter().chain(old_items("i", index)) {
|
||||
let rect = ["x", "y", "w", "h"].map(|k| image.num(k).unwrap_or(0.0) as f32);
|
||||
let corners = [
|
||||
glam::Vec2::new(rect[0], rect[1]),
|
||||
glam::Vec2::new(rect[0] + rect[2], rect[1]),
|
||||
glam::Vec2::new(rect[0] + rect[2], rect[1] + rect[3]),
|
||||
glam::Vec2::new(rect[0], rect[1] + rect[3]),
|
||||
]
|
||||
.map(map);
|
||||
match image_asset(image, source, &assets, corners) {
|
||||
Some(asset) => strokes.push(import::image_stroke(new_id(), asset, corners)),
|
||||
None => report.skipped += 1,
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(delta) = xpage.get("q") {
|
||||
let text = quill_text(delta);
|
||||
if !text.is_empty() {
|
||||
// Saber's text area starts 1.2 lines down, half a line in from the sides
|
||||
let origin = glam::Vec2::new(line_height * 0.5, line_height * 1.2);
|
||||
strokes.push(import::text_stroke(
|
||||
new_id(),
|
||||
&text,
|
||||
line_height * 0.8 * scale,
|
||||
TextFont::Sans,
|
||||
map(origin),
|
||||
[glam::Vec2::X, glam::Vec2::Y],
|
||||
Some((page_size.x - line_height) * scale),
|
||||
[0.0, 0.0, 0.0, 1.0],
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
let page_strokes = xpage.get("s").map(Bson::as_array).unwrap_or_default();
|
||||
for stroke in page_strokes.iter().chain(old_items("s", index)) {
|
||||
let tool = stroke
|
||||
.get("ty")
|
||||
.and_then(Bson::as_str)
|
||||
.unwrap_or("fountainPen");
|
||||
if tool == "Eraser" || tool == "LaserPointer" {
|
||||
continue;
|
||||
}
|
||||
let size = stroke.num("s").unwrap_or(10.0) as f32;
|
||||
let thinning = stroke.num("t").unwrap_or(0.5) as f32;
|
||||
let pressure_enabled = stroke.bool("pe").unwrap_or(true);
|
||||
let (points, pressures): (Vec<glam::Vec2>, Vec<Option<f32>>) =
|
||||
match stroke.get("shape").and_then(Bson::as_str) {
|
||||
Some(shape) => shape_points(stroke, shape)
|
||||
.into_iter()
|
||||
.map(|p| (p, None))
|
||||
.unzip(),
|
||||
None => points(stroke).into_iter().unzip(),
|
||||
};
|
||||
if points.is_empty() {
|
||||
report.skipped += 1;
|
||||
continue;
|
||||
}
|
||||
let widths: Vec<f32> = pressures
|
||||
.iter()
|
||||
.map(|p| {
|
||||
let p = p
|
||||
.filter(|_| pressure_enabled)
|
||||
.unwrap_or(0.5)
|
||||
.clamp(0.0, 1.0);
|
||||
(size * (1.0 - thinning * (1.0 - 2.0 * p))).max(size * 0.05) * scale
|
||||
})
|
||||
.collect();
|
||||
let (width, pressures) = import::fit_widths(&widths);
|
||||
let points: Vec<glam::Vec2> = points.into_iter().map(map).collect();
|
||||
strokes.push(import::ink_stroke(
|
||||
new_id(),
|
||||
argb(stroke.get("c")),
|
||||
tool == "Highlighter",
|
||||
&points,
|
||||
width,
|
||||
&pressures,
|
||||
));
|
||||
}
|
||||
|
||||
pages.push(import::page(
|
||||
index as u64 + 1,
|
||||
size,
|
||||
background,
|
||||
pdf_background,
|
||||
strokes,
|
||||
));
|
||||
}
|
||||
Imported {
|
||||
pages,
|
||||
next_stroke_id: next_id,
|
||||
layout: None,
|
||||
report,
|
||||
}
|
||||
}
|
||||
|
||||
/// Stores the image of `image` (asset file, inline bytes or SVG text) in our asset folder.
|
||||
fn image_asset(
|
||||
image: &Bson,
|
||||
source: &Source,
|
||||
assets: &crate::assets::AssetStore,
|
||||
corners: [glam::Vec2; 4],
|
||||
) -> Option<String> {
|
||||
let bytes = match (image.num("a"), image.get("b")) {
|
||||
(Some(a), _) => (source.assets)(a as usize)?,
|
||||
(None, Some(Bson::Binary(b))) => b.clone(),
|
||||
(None, Some(Bson::Array(b))) => b.iter().filter_map(|v| Some(v.as_i64()? as u8)).collect(),
|
||||
(None, Some(Bson::Str(svg))) => svg.clone().into_bytes(),
|
||||
_ => return None,
|
||||
};
|
||||
if image.get("e").and_then(Bson::as_str) == Some(".svg") {
|
||||
let size = glam::Vec2::new(
|
||||
corners[0].distance(corners[1]),
|
||||
corners[0].distance(corners[3]),
|
||||
);
|
||||
let (w, h, pixels) = import::rasterize_svg(std::str::from_utf8(&bytes).ok()?, size)?;
|
||||
return assets.import_rgba(w, h, pixels).ok().map(|i| i.asset);
|
||||
}
|
||||
assets.import_bytes(&bytes).ok().map(|i| i.asset)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::document::StrokeTool;
|
||||
|
||||
/// BSON encoder for test files.
|
||||
fn encode(value: &Bson) -> Vec<u8> {
|
||||
fn doc(fields: &[(String, Bson)]) -> Vec<u8> {
|
||||
let mut body = Vec::new();
|
||||
for (key, value) in fields {
|
||||
let (kind, data): (u8, Vec<u8>) = match value {
|
||||
Bson::Null => (0x0A, Vec::new()),
|
||||
Bson::Bool(b) => (0x08, vec![*b as u8]),
|
||||
Bson::Int(i) => (0x12, i.to_le_bytes().to_vec()),
|
||||
Bson::Double(d) => (0x01, d.to_le_bytes().to_vec()),
|
||||
Bson::Str(s) => {
|
||||
let mut d = ((s.len() + 1) as i32).to_le_bytes().to_vec();
|
||||
d.extend(s.as_bytes());
|
||||
d.push(0);
|
||||
(0x02, d)
|
||||
}
|
||||
Bson::Binary(b) => {
|
||||
let mut d = (b.len() as i32).to_le_bytes().to_vec();
|
||||
d.push(0);
|
||||
d.extend(b);
|
||||
(0x05, d)
|
||||
}
|
||||
Bson::Array(items) => {
|
||||
let fields: Vec<(String, Bson)> = items
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, v)| (i.to_string(), v.clone()))
|
||||
.collect();
|
||||
(0x04, doc(&fields))
|
||||
}
|
||||
Bson::Doc(fields) => (0x03, doc(fields)),
|
||||
};
|
||||
body.push(kind);
|
||||
body.extend(key.as_bytes());
|
||||
body.push(0);
|
||||
body.extend(data);
|
||||
}
|
||||
let mut out = ((body.len() + 5) as i32).to_le_bytes().to_vec();
|
||||
out.extend(body);
|
||||
out.push(0);
|
||||
out
|
||||
}
|
||||
match value {
|
||||
Bson::Doc(fields) => doc(fields),
|
||||
_ => panic!("only documents"),
|
||||
}
|
||||
}
|
||||
|
||||
fn d(fields: &[(&str, Bson)]) -> Bson {
|
||||
Bson::Doc(
|
||||
fields
|
||||
.iter()
|
||||
.map(|(k, v)| (k.to_string(), v.clone()))
|
||||
.collect(),
|
||||
)
|
||||
}
|
||||
|
||||
fn point(x: f32, y: f32, p: f32) -> Bson {
|
||||
Bson::Binary([x, y, p].iter().flat_map(|v| v.to_le_bytes()).collect())
|
||||
}
|
||||
|
||||
fn png() -> Vec<u8> {
|
||||
let image = image::RgbaImage::from_pixel(4, 2, image::Rgba([200, 30, 30, 255]));
|
||||
let mut png = Vec::new();
|
||||
image
|
||||
.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png)
|
||||
.unwrap();
|
||||
png
|
||||
}
|
||||
|
||||
/// Version 19 document: page 1 with ink, highlighter, a circle, an image and text;
|
||||
/// page 2 with a PDF page as background. Assets: 0 = PNG, 1 = PDF.
|
||||
fn sample() -> Bson {
|
||||
let pen = d(&[
|
||||
(
|
||||
"p",
|
||||
Bson::Array(vec![point(100.0, 100.0, 1.0), point(200.0, 100.0, 0.25)]),
|
||||
),
|
||||
("ty", Bson::Str("fountainPen".into())),
|
||||
("pe", Bson::Bool(true)),
|
||||
("c", Bson::Int(0xff0000ff)),
|
||||
("s", Bson::Double(10.0)),
|
||||
("t", Bson::Double(0.5)),
|
||||
("ox", Bson::Double(10.0)),
|
||||
]);
|
||||
let marker = d(&[
|
||||
(
|
||||
"p",
|
||||
Bson::Array(vec![point(0.0, 300.0, 0.5), point(300.0, 300.0, 0.5)]),
|
||||
),
|
||||
("ty", Bson::Str("Highlighter".into())),
|
||||
("c", Bson::Int(0x80ffff00)),
|
||||
("s", Bson::Double(40.0)),
|
||||
]);
|
||||
let circle = d(&[
|
||||
("shape", Bson::Str("circle".into())),
|
||||
("cx", Bson::Double(500.0)),
|
||||
("cy", Bson::Double(500.0)),
|
||||
("r", Bson::Double(50.0)),
|
||||
("c", Bson::Int(0xff00ff00)),
|
||||
("s", Bson::Double(5.0)),
|
||||
]);
|
||||
let image = d(&[
|
||||
("e", Bson::Str(".png".into())),
|
||||
("a", Bson::Int(0)),
|
||||
("x", Bson::Double(600.0)),
|
||||
("y", Bson::Double(100.0)),
|
||||
("w", Bson::Double(200.0)),
|
||||
("h", Bson::Double(100.0)),
|
||||
]);
|
||||
let text = Bson::Array(vec![
|
||||
d(&[("insert", Bson::Str("Hallo ".into()))]),
|
||||
d(&[("insert", Bson::Str("Welt\n".into()))]),
|
||||
]);
|
||||
let page1 = d(&[
|
||||
("w", Bson::Double(1000.0)),
|
||||
("h", Bson::Double(1400.0)),
|
||||
("s", Bson::Array(vec![pen, marker, circle])),
|
||||
("i", Bson::Array(vec![image])),
|
||||
("q", text),
|
||||
]);
|
||||
let pdf = d(&[
|
||||
("e", Bson::Str(".pdf".into())),
|
||||
("a", Bson::Int(1)),
|
||||
("pdfi", Bson::Int(1)),
|
||||
("nw", Bson::Double(842.0)),
|
||||
("nh", Bson::Double(595.0)),
|
||||
]);
|
||||
let page2 = d(&[
|
||||
("w", Bson::Double(1000.0)),
|
||||
("h", Bson::Double(707.0)),
|
||||
("b", pdf),
|
||||
]);
|
||||
d(&[
|
||||
("v", Bson::Int(19)),
|
||||
("p", Bson::Str("dots".into())),
|
||||
("l", Bson::Int(40)),
|
||||
("z", Bson::Array(vec![page1, page2])),
|
||||
])
|
||||
}
|
||||
|
||||
fn sample_pdf() -> Vec<u8> {
|
||||
crate::pdf_doc::tests::sample_pdf(&[(842.0, 595.0, 0), (842.0, 595.0, 0)])
|
||||
}
|
||||
|
||||
fn check(notebook: &Notebook, report: &ImportReport) {
|
||||
assert_eq!(*report, ImportReport::default());
|
||||
assert_eq!(notebook.pages.len(), 2);
|
||||
let first = ¬ebook.pages[0];
|
||||
assert_eq!(first.background, PageBackground::DotGrid);
|
||||
// Image, text, then the strokes
|
||||
assert_eq!(first.strokes.len(), 5);
|
||||
assert!(first.strokes[0].is_image());
|
||||
assert_eq!(
|
||||
first.strokes[1].text.as_ref().unwrap().content,
|
||||
"Hallo Welt"
|
||||
);
|
||||
let pen = &first.strokes[2];
|
||||
assert_eq!(pen.tool, StrokeTool::Pen);
|
||||
assert_eq!(pen.color, [0.0, 0.0, 1.0, 1.0]);
|
||||
// 1000 Saber units = 210 mm; the offset moves the stroke by 10
|
||||
assert!((pen.points[0].position.x - 231.0).abs() < 0.1);
|
||||
// perfect_freehand: size 10, thinning 0.5 gives 15 at full pressure and 7.5 at a
|
||||
// quarter, scaled by 2.1
|
||||
let drawn: Vec<f32> = pen
|
||||
.points
|
||||
.iter()
|
||||
.map(|s| pen.width * (0.5 + s.pressure))
|
||||
.collect();
|
||||
assert!((drawn[0] - 31.5).abs() < 0.01 && (drawn[1] - 15.75).abs() < 0.01);
|
||||
assert_eq!(first.strokes[3].tool, StrokeTool::Highlighter);
|
||||
assert!((first.strokes[3].color[3] - 128.0 / 255.0).abs() < 1e-3);
|
||||
assert_eq!(first.strokes[4].points.len(), 65);
|
||||
let bg = notebook.pages[1].pdf_background.as_ref().unwrap();
|
||||
assert_eq!(bg.page, 1);
|
||||
assert!(notebook.pages[1].size.x > notebook.pages[1].size.y);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_bson() {
|
||||
let value = sample();
|
||||
assert_eq!(parse_bson(&encode(&value)).unwrap(), value);
|
||||
assert!(parse_bson(b"\x05\x00\x00").is_err());
|
||||
let mut broken = encode(&value);
|
||||
broken.truncate(broken.len() - 3);
|
||||
assert!(parse_bson(&broken).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_import_sbn2_with_assets_next_to_it() {
|
||||
let temp_dir = tempfile::tempdir().unwrap();
|
||||
let source_dir = tempfile::tempdir().unwrap();
|
||||
let path = source_dir.path().join("Mathe.sbn2");
|
||||
std::fs::write(&path, encode(&sample())).unwrap();
|
||||
std::fs::write(source_dir.path().join("Mathe.sbn2.0"), png()).unwrap();
|
||||
std::fs::write(source_dir.path().join("Mathe.sbn2.1"), sample_pdf()).unwrap();
|
||||
let (notebook, _, dir, report) =
|
||||
create_notebook_from_saber(temp_dir.path(), "Mathe", &path).unwrap();
|
||||
check(¬ebook, &report);
|
||||
// Saved and loaded again
|
||||
let (mut reloaded, _) = crate::storage::load_notebook(&dir).unwrap();
|
||||
crate::storage::load_all_pages(&dir, &mut reloaded).unwrap();
|
||||
check(&reloaded, &report);
|
||||
}
|
||||
|
||||
/// Zip archive with stored (uncompressed) files.
|
||||
fn zip(files: &[(&str, Vec<u8>)]) -> Vec<u8> {
|
||||
let mut out = Vec::new();
|
||||
let mut central = Vec::new();
|
||||
for (name, data) in files {
|
||||
let offset = out.len() as u32;
|
||||
let header = |sig: &[u8], central: bool| {
|
||||
let mut h = sig.to_vec();
|
||||
if central {
|
||||
h.extend([20, 0]);
|
||||
}
|
||||
h.extend([20, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]);
|
||||
h.extend((data.len() as u32).to_le_bytes());
|
||||
h.extend((data.len() as u32).to_le_bytes());
|
||||
h.extend((name.len() as u16).to_le_bytes());
|
||||
h.extend([0, 0]);
|
||||
if central {
|
||||
h.extend([0; 10]);
|
||||
h.extend(offset.to_le_bytes());
|
||||
}
|
||||
h.extend(name.as_bytes());
|
||||
h
|
||||
};
|
||||
out.extend(header(&[0x50, 0x4b, 0x03, 0x04], false));
|
||||
out.extend(data);
|
||||
central.extend(header(&[0x50, 0x4b, 0x01, 0x02], true));
|
||||
}
|
||||
let start = out.len() as u32;
|
||||
out.extend(¢ral);
|
||||
out.extend([0x50, 0x4b, 0x05, 0x06, 0, 0, 0, 0]);
|
||||
out.extend((files.len() as u16).to_le_bytes());
|
||||
out.extend((files.len() as u16).to_le_bytes());
|
||||
out.extend((central.len() as u32).to_le_bytes());
|
||||
out.extend(start.to_le_bytes());
|
||||
out.extend([0, 0]);
|
||||
out
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_import_sba_archive() {
|
||||
let temp_dir = tempfile::tempdir().unwrap();
|
||||
let source_dir = tempfile::tempdir().unwrap();
|
||||
let path = source_dir.path().join("Mathe.sba");
|
||||
let archive = zip(&[
|
||||
("main.sbn2", encode(&sample())),
|
||||
("main.sbn2.0", png()),
|
||||
("main.sbn2.1", sample_pdf()),
|
||||
]);
|
||||
std::fs::write(&path, archive).unwrap();
|
||||
let (notebook, _, _, report) =
|
||||
create_notebook_from_saber(temp_dir.path(), "Mathe", &path).unwrap();
|
||||
check(¬ebook, &report);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_old_json_files() {
|
||||
let temp_dir = tempfile::tempdir().unwrap();
|
||||
let source_dir = tempfile::tempdir().unwrap();
|
||||
// Version 7: strokes outside the pages, points as {x, y, p}
|
||||
let path = source_dir.path().join("Alt.sbn");
|
||||
std::fs::write(
|
||||
&path,
|
||||
r#"{"v": 7, "z": [{"w": 1000, "h": 1400}, {"w": 1000, "h": 1400}],
|
||||
"s": [{"i": 1, "p": [{"x": 0, "y": 0, "p": 0.5}, {"x": 100, "y": 0, "p": 0.5}]}]}"#,
|
||||
)
|
||||
.unwrap();
|
||||
let (notebook, _, _, _) =
|
||||
create_notebook_from_saber(temp_dir.path(), "Alt", &path).unwrap();
|
||||
assert_eq!(notebook.pages[0].strokes.len(), 0);
|
||||
assert_eq!(notebook.pages[1].strokes.len(), 1);
|
||||
|
||||
// The very first format: just a list of strokes
|
||||
let path = source_dir.path().join("Uralt.sbn");
|
||||
std::fs::write(&path, "[]").unwrap();
|
||||
assert!(create_notebook_from_saber(temp_dir.path(), "Uralt", &path).is_err());
|
||||
}
|
||||
}
|
||||
@@ -669,7 +669,7 @@ impl UiState {
|
||||
self.open_file_picker(PickerKind::Image);
|
||||
}
|
||||
|
||||
/// Opens the file picker "Import" (PDF, Xournal++, Rnote) in the last used folder.
|
||||
/// Opens the file picker "Import" (PDF, Xournal++, Rnote, Saber) in the last used folder.
|
||||
pub fn open_import_picker(&mut self) {
|
||||
self.open_file_picker(PickerKind::Import);
|
||||
}
|
||||
@@ -2831,12 +2831,12 @@ fn file_picker_dialog(ctx: &egui::Context, state: &mut UiState, actions: &mut Ve
|
||||
t!("Importieren"),
|
||||
t!("📄 Importieren"),
|
||||
"📄",
|
||||
t!("Keine Ordner, PDF-, Xournal++- oder Rnote-Dateien"),
|
||||
t!("Keine Ordner oder importierbaren Dateien (PDF, Xournal++, Rnote, Saber)"),
|
||||
t!(
|
||||
"Die Datei wird als neues Notizbuch angelegt. PDF: eine Seite je PDF-Seite, \
|
||||
beim Export liegen deine Notizen über den Originalseiten. \
|
||||
Xournal++ (.xopp, .xoj) und Rnote (.rnote): Striche, Formen, Texte und Bilder \
|
||||
werden übernommen, bei Xournal++ auch PDF-Hintergründe."
|
||||
Xournal++ (.xopp, .xoj), Rnote (.rnote) und Saber (.sbn2, .sba): Striche, Formen, \
|
||||
Texte und Bilder werden übernommen, bei Xournal++ und Saber auch PDF-Hintergründe."
|
||||
),
|
||||
),
|
||||
};
|
||||
@@ -4197,8 +4197,8 @@ pub fn render_ui(
|
||||
state.open_image_picker();
|
||||
}
|
||||
if ui
|
||||
.add_sized([ui.available_width(), 30.0], egui::Button::new(t!("📄 Importieren (PDF, Xournal++, Rnote) …")))
|
||||
.on_hover_text(t!("Vorlesungsfolien, Übungsblätter, Formulare oder Notizbücher aus Xournal++ und Rnote als neues Notizbuch öffnen und darauf schreiben"))
|
||||
.add_sized([ui.available_width(), 30.0], egui::Button::new(t!("📄 Importieren (PDF, Xournal++, Rnote, Saber) …")))
|
||||
.on_hover_text(t!("Vorlesungsfolien, Übungsblätter, Formulare oder Notizbücher aus Xournal++, Rnote und Saber als neues Notizbuch öffnen und darauf schreiben"))
|
||||
.clicked()
|
||||
{
|
||||
state.open_import_picker();
|
||||
|
||||
Reference in new issue
Block a user