feat: import Rnote notebooks (Issue #47)
The import (menu entry and drag & drop) now also accepts .rnote files from Rnote 0.5.10 on, including the transformation and document config layouts that changed in 0.13 and 0.15. Taken over: brush strokes with their pressure curve, shapes as outlines, texts, bitmap images and SVG images, which are rasterized with resvg (Rnote stores imported PDF pages as SVG). Fixed-size and continuous documents keep their pages; infinite ones become an endless notebook. The parts shared with the Xournal++ import moved to src/import.rs. resvg and its dependencies were added to the Flatpak cargo sources. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
2bd697ef82
commit
46026eb8e5
14 files changed
+1567
-187
No files matched your search
+3
-11
@@ -2216,9 +2216,9 @@ pub fn run() -> Result<(), Box<dyn std::error::Error>> {
|
||||
}
|
||||
}
|
||||
|
||||
// Bilddatei ins Fenster gezogen: an der Ablagestelle einfügen; PDF/Xournal++: importieren
|
||||
// Bilddatei ins Fenster gezogen: an der Ablagestelle einfügen; PDF/Xournal++/Rnote: importieren
|
||||
WindowEvent::DroppedFile(path) => {
|
||||
if crate::file_picker::is_pdf_path(&path) || crate::xopp::is_xopp_path(&path) {
|
||||
if crate::import::is_importable_path(&path) {
|
||||
ui_state.queued_actions.push(UiAction::ImportFile(path));
|
||||
} else if crate::assets::is_supported_image_path(&path) {
|
||||
let (sx, sy) = cursor_screen_pos;
|
||||
@@ -3844,15 +3844,7 @@ pub fn run() -> Result<(), Box<dyn std::error::Error>> {
|
||||
.file_stem()
|
||||
.map(|s| s.to_string_lossy().into_owned())
|
||||
.unwrap_or_else(|| "Import".to_string());
|
||||
let result = if crate::xopp::is_xopp_path(&path) {
|
||||
crate::xopp::create_notebook_from_xopp(&storage_base_dir, &title, &path)
|
||||
} else {
|
||||
std::fs::read(&path)
|
||||
.map_err(|e| e.to_string())
|
||||
.and_then(|bytes| storage::create_notebook_from_pdf(&storage_base_dir, &title, bytes))
|
||||
.map(|(nb, m, dir)| (nb, m, dir, crate::xopp::ImportReport::default()))
|
||||
};
|
||||
match result {
|
||||
match crate::import::import_file(&storage_base_dir, &title, &path) {
|
||||
Ok((new_nb, new_manifest, new_dir, report)) => {
|
||||
let id = new_manifest.id.clone();
|
||||
change_library(&storage_base_dir, &mut ui_state, |library| {
|
||||
|
||||
+11
-4
@@ -1,4 +1,4 @@
|
||||
//! Eingebaute, stift- und fingertaugliche Dateiauswahl für Bilder, PDFs und Xournal++-Dateien.
|
||||
//! Eingebaute, stift- und fingertaugliche Dateiauswahl für Bilder und importierbare Dateien (PDF, Xournal++, Rnote).
|
||||
//!
|
||||
//! 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 or Xournal++ file to import as a new notebook
|
||||
/// PDF, Xournal++ or Rnote file to import as a new notebook
|
||||
Import,
|
||||
}
|
||||
|
||||
@@ -31,7 +31,7 @@ impl PickerKind {
|
||||
pub fn accepts(self, path: &Path) -> bool {
|
||||
match self {
|
||||
PickerKind::Image => is_supported_image_path(path),
|
||||
PickerKind::Import => is_pdf_path(path) || crate::xopp::is_xopp_path(path),
|
||||
PickerKind::Import => crate::import::is_importable_path(path),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -193,6 +193,7 @@ mod tests {
|
||||
"c.webp",
|
||||
"Skript.PDF",
|
||||
"mathe.xopp",
|
||||
"skizze.rnote",
|
||||
] {
|
||||
std::fs::write(dir.path().join(name), b"x").unwrap();
|
||||
}
|
||||
@@ -213,7 +214,13 @@ mod tests {
|
||||
);
|
||||
assert_eq!(
|
||||
names(PickerKind::Import),
|
||||
["aOrdner", "zOrdner", "mathe.xopp", "Skript.PDF"]
|
||||
[
|
||||
"aOrdner",
|
||||
"zOrdner",
|
||||
"mathe.xopp",
|
||||
"skizze.rnote",
|
||||
"Skript.PDF"
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -167,6 +167,8 @@ mod tests {
|
||||
("text.rs", include_str!("text.rs")),
|
||||
("ui.rs", include_str!("ui.rs")),
|
||||
("updater.rs", include_str!("updater.rs")),
|
||||
("import.rs", include_str!("import.rs")),
|
||||
("rnote.rs", include_str!("rnote.rs")),
|
||||
("xopp.rs", include_str!("xopp.rs")),
|
||||
];
|
||||
|
||||
|
||||
+6
-4
@@ -450,14 +450,16 @@ pub const EN: &[(&str, &str)] = &[
|
||||
(", PDF-Hintergrund nicht gefunden: {file}", ", PDF background not found: {file}"),
|
||||
(", {count} Elemente übersprungen", ", {count} elements skipped"),
|
||||
("Nicht importiert: {err}", "Not imported: {err}"),
|
||||
("Die Datei wird als neues Notizbuch angelegt. PDF: eine Seite je PDF-Seite, beim Export liegen deine Notizen über den Originalseiten. Xournal++ (.xopp, .xoj): Striche, Texte, Bilder und PDF-Hintergründe werden übernommen.", "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): strokes, texts, images and PDF backgrounds are taken over."),
|
||||
("Importieren", "Import"),
|
||||
("Keine Ordner, PDF- oder Xournal++-Dateien", "No folders, PDF or Xournal++ files"),
|
||||
("Vorlesungsfolien, Übungsblätter, Formulare oder Xournal++-Notizbücher als neues Notizbuch öffnen und darauf schreiben", "Open lecture slides, worksheets, forms or Xournal++ notebooks as a new notebook and write on them"),
|
||||
("📄 Importieren (PDF, Xournal++) …", "📄 Import (PDF, Xournal++) …"),
|
||||
("📄 Importieren", "📄 Import"),
|
||||
("Datei beschädigt: {err}", "File damaged: {err}"),
|
||||
("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"),
|
||||
];
|
||||
+257
@@ -0,0 +1,257 @@
|
||||
//! 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.
|
||||
|
||||
use std::io::Read;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use crate::document::{Notebook, Page, PageBackground, StrokeTool};
|
||||
use crate::storage::{self, NotebookLayout, NotebookManifest, SampleData, StrokeData};
|
||||
use crate::t;
|
||||
use crate::text::{TextBox, TextFont};
|
||||
|
||||
/// Largest accepted file after decompression (guards against gzip bombs).
|
||||
const MAX_UNCOMPRESSED_BYTES: u64 = 512 * 1024 * 1024;
|
||||
|
||||
/// Alpha of imported highlighter strokes that are stored opaque (as the app's highlighters).
|
||||
pub const HIGHLIGHTER_ALPHA: f32 = 0.35;
|
||||
|
||||
/// What could not be taken over.
|
||||
#[derive(Debug, Clone, Default, PartialEq)]
|
||||
pub struct ImportReport {
|
||||
pub skipped: usize,
|
||||
/// The PDF background was referenced but not found or unreadable
|
||||
pub missing_pdf: Option<String>,
|
||||
}
|
||||
|
||||
/// Result of an importer: pages, the next free stroke id, the layout (`None`: default).
|
||||
pub struct Imported {
|
||||
pub pages: Vec<Page>,
|
||||
pub next_stroke_id: u64,
|
||||
pub layout: Option<NotebookLayout>,
|
||||
pub report: ImportReport,
|
||||
}
|
||||
|
||||
/// Can the file be imported (by its name)?
|
||||
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)
|
||||
}
|
||||
|
||||
/// Imports a PDF, Xournal++ or Rnote file as a new notebook titled `title`.
|
||||
pub fn import_file(
|
||||
base_dir: &Path,
|
||||
title: &str,
|
||||
path: &Path,
|
||||
) -> Result<(Notebook, NotebookManifest, PathBuf, ImportReport), String> {
|
||||
if crate::xopp::is_xopp_path(path) {
|
||||
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 {
|
||||
let bytes = std::fs::read(path).map_err(|e| e.to_string())?;
|
||||
storage::create_notebook_from_pdf(base_dir, title, bytes)
|
||||
.map(|(nb, manifest, dir)| (nb, manifest, dir, ImportReport::default()))
|
||||
}
|
||||
}
|
||||
|
||||
/// Decompresses gzip data; other data is returned unchanged.
|
||||
pub fn gunzip(bytes: &[u8]) -> Result<Vec<u8>, String> {
|
||||
if !bytes.starts_with(&[0x1f, 0x8b]) {
|
||||
return Ok(bytes.to_vec());
|
||||
}
|
||||
let mut data = Vec::new();
|
||||
flate2::read::MultiGzDecoder::new(bytes)
|
||||
.take(MAX_UNCOMPRESSED_BYTES)
|
||||
.read_to_end(&mut data)
|
||||
.map_err(|e| t!("Datei nicht lesbar: {err}", err = e))?;
|
||||
Ok(data)
|
||||
}
|
||||
|
||||
/// Creates a new notebook and fills it with the pages `build` creates in its folder (images
|
||||
/// and PDFs go to its asset folder). On failure nothing is left behind.
|
||||
pub fn create_notebook(
|
||||
base_dir: &Path,
|
||||
title: &str,
|
||||
build: impl FnOnce(&Path) -> Result<Imported, String>,
|
||||
) -> Result<(Notebook, NotebookManifest, PathBuf, ImportReport), String> {
|
||||
let (_, _, dir) = storage::create_new_notebook(base_dir, title).map_err(|e| e.to_string())?;
|
||||
let result = (|| {
|
||||
let imported = build(&dir)?;
|
||||
let page_count = imported.pages.len() as u64;
|
||||
let notebook =
|
||||
Notebook::from_pages(imported.pages, 0, imported.next_stroke_id, page_count + 1);
|
||||
let mut manifest = storage::load_manifest(&dir).map_err(|e| e.to_string())?;
|
||||
if imported.layout.is_some() {
|
||||
manifest.layout = imported.layout;
|
||||
}
|
||||
storage::save_notebook(&dir, ¬ebook, &mut manifest).map_err(|e| e.to_string())?;
|
||||
Ok((notebook, manifest, imported.report))
|
||||
})();
|
||||
match result {
|
||||
Ok((notebook, manifest, report)) => Ok((notebook, manifest, dir, report)),
|
||||
Err(err) => {
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
Err(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Builds a page from stored-format strokes.
|
||||
pub fn page(
|
||||
id: u64,
|
||||
size: glam::Vec2,
|
||||
background: PageBackground,
|
||||
pdf_background: Option<crate::document::PdfBackground>,
|
||||
strokes: Vec<StrokeData>,
|
||||
) -> Page {
|
||||
storage::PageData {
|
||||
format_version: storage::CURRENT_FORMAT_VERSION,
|
||||
id,
|
||||
background,
|
||||
pdf_background,
|
||||
title: None,
|
||||
size: (size != crate::document::A4_SIZE).then(|| size.to_array()),
|
||||
strokes,
|
||||
}
|
||||
.to_page()
|
||||
}
|
||||
|
||||
/// Our stroke width and the sample pressures for absolute widths per point (page units):
|
||||
/// we draw a diameter of `width · (0.5 + p)`, so the widest point gets p = 1 and thinner
|
||||
/// ones less (down to a third of the widest).
|
||||
pub fn fit_widths(widths: &[f32]) -> (f32, Vec<f32>) {
|
||||
let widest = widths.iter().copied().fold(0.0, f32::max).max(0.01);
|
||||
let width = widest / 1.5;
|
||||
let pressures = widths
|
||||
.iter()
|
||||
.map(|w| (w / width - 0.5).clamp(0.0, 1.0))
|
||||
.collect();
|
||||
(width, pressures)
|
||||
}
|
||||
|
||||
/// Pen or highlighter stroke through `points` (page units) with the given widths per point.
|
||||
pub fn ink_stroke(
|
||||
id: u64,
|
||||
mut color: [f32; 4],
|
||||
highlighter: bool,
|
||||
points: &[glam::Vec2],
|
||||
width: f32,
|
||||
pressures: &[f32],
|
||||
) -> StrokeData {
|
||||
let tool = if highlighter {
|
||||
if color[3] >= 0.99 {
|
||||
color[3] = HIGHLIGHTER_ALPHA;
|
||||
}
|
||||
StrokeTool::Highlighter
|
||||
} else {
|
||||
// Pens are always opaque (translucent pens are read as highlighters)
|
||||
color[3] = 1.0;
|
||||
StrokeTool::Pen
|
||||
};
|
||||
let samples = points
|
||||
.iter()
|
||||
.zip(pressures)
|
||||
.enumerate()
|
||||
.map(|(i, (p, &pressure))| SampleData {
|
||||
x: p.x,
|
||||
y: p.y,
|
||||
p: pressure,
|
||||
t: i as f64 * 0.005,
|
||||
})
|
||||
.collect();
|
||||
StrokeData {
|
||||
id,
|
||||
color,
|
||||
samples,
|
||||
shape: None,
|
||||
tool,
|
||||
width,
|
||||
finalized: true,
|
||||
image: None,
|
||||
text: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Text field with its upper left corner at `origin` and the axes (page vector per unit to
|
||||
/// the right and down). Without `wrap_width` it is as wide as its longest line.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn text_stroke(
|
||||
id: u64,
|
||||
content: &str,
|
||||
size: f32,
|
||||
font: TextFont,
|
||||
origin: glam::Vec2,
|
||||
axes: [glam::Vec2; 2],
|
||||
wrap_width: Option<f32>,
|
||||
mut color: [f32; 4],
|
||||
) -> StrokeData {
|
||||
let wrap_width = wrap_width.unwrap_or_else(|| {
|
||||
let unwrapped = TextBox::new(content.to_string(), size, font, f32::MAX / 4.0);
|
||||
let widest = unwrapped
|
||||
.layout()
|
||||
.lines
|
||||
.iter()
|
||||
.map(|line| line.width)
|
||||
.fold(0.0, f32::max);
|
||||
widest + size
|
||||
});
|
||||
let text = TextBox::new(content.to_string(), size, font, wrap_width);
|
||||
let corners = crate::text::frame_corners(origin, axes, text.box_size());
|
||||
color[3] = 1.0;
|
||||
StrokeData {
|
||||
id,
|
||||
color,
|
||||
samples: corner_samples(corners),
|
||||
shape: None,
|
||||
tool: StrokeTool::Pen,
|
||||
width: 0.0,
|
||||
finalized: true,
|
||||
image: None,
|
||||
text: Some(text),
|
||||
}
|
||||
}
|
||||
|
||||
/// Image with the corners upper left, upper right, lower right, lower left.
|
||||
pub fn image_stroke(id: u64, asset: String, corners: [glam::Vec2; 4]) -> StrokeData {
|
||||
StrokeData {
|
||||
id,
|
||||
color: [1.0, 1.0, 1.0, 1.0],
|
||||
samples: corner_samples(corners),
|
||||
shape: None,
|
||||
tool: StrokeTool::Pen,
|
||||
width: 0.0,
|
||||
finalized: true,
|
||||
image: Some(asset),
|
||||
text: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn corner_samples(corners: [glam::Vec2; 4]) -> Vec<SampleData> {
|
||||
corners
|
||||
.iter()
|
||||
.map(|c| SampleData {
|
||||
x: c.x,
|
||||
y: c.y,
|
||||
p: 1.0,
|
||||
t: 0.0,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_fit_widths_keeps_the_drawn_widths() {
|
||||
let (width, pressures) = fit_widths(&[30.0, 15.0, 10.0, 2.0]);
|
||||
let drawn: Vec<f32> = pressures.iter().map(|p| width * (0.5 + p)).collect();
|
||||
assert!((drawn[0] - 30.0).abs() < 1e-3);
|
||||
assert!((drawn[1] - 15.0).abs() < 1e-3);
|
||||
assert!((drawn[2] - 10.0).abs() < 1e-3);
|
||||
// Thinner than a third of the widest point is not possible
|
||||
assert!((drawn[3] - 10.0).abs() < 1e-3);
|
||||
}
|
||||
}
|
||||
@@ -13,12 +13,14 @@ pub mod geometry;
|
||||
pub mod history;
|
||||
pub mod i18n;
|
||||
pub mod image_layer;
|
||||
pub mod import;
|
||||
pub mod library;
|
||||
pub mod osk;
|
||||
pub mod page_export;
|
||||
pub mod pdf_doc;
|
||||
pub mod pdf_merge;
|
||||
pub mod platform;
|
||||
pub mod rnote;
|
||||
pub mod ruler;
|
||||
pub mod selection;
|
||||
pub mod settings;
|
||||
|
||||
+938
@@ -0,0 +1,938 @@
|
||||
//! Import of Rnote notebooks (.rnote) (Issue #47).
|
||||
//!
|
||||
//! An .rnote file is gzip-compressed JSON: `{"version": "0.x.y", "data": {"engine_snapshot":
|
||||
//! {...}}}`. The snapshot is Rnote's internal state without a specification, so it is read
|
||||
//! leniently as JSON and only what we need is picked out; files from Rnote 0.5.10 on are
|
||||
//! supported (the layout of older ones differs too much). Coordinates are pixels at the
|
||||
//! document's dpi.
|
||||
//!
|
||||
//! Pages: fixed-size and continuous documents become one page per Rnote page (a stroke goes
|
||||
//! to the page its middle lies on), infinite documents an endless notebook.
|
||||
|
||||
use base64::Engine;
|
||||
use glam::DVec2;
|
||||
use serde_json::Value;
|
||||
use std::path::Path;
|
||||
|
||||
use crate::document::{Notebook, PageBackground};
|
||||
use crate::import::{self, ImportReport, Imported};
|
||||
use crate::storage::{NotebookLayout, NotebookManifest, StrokeData};
|
||||
use crate::t;
|
||||
use crate::text::TextFont;
|
||||
|
||||
/// At most this many pages are created (protects against nonsensical document sizes).
|
||||
const MAX_PAGES: usize = 2000;
|
||||
|
||||
/// Is the file by its name an Rnote notebook?
|
||||
pub fn is_rnote_path(path: &Path) -> bool {
|
||||
path.extension()
|
||||
.and_then(|e| e.to_str())
|
||||
.is_some_and(|e| e.eq_ignore_ascii_case("rnote"))
|
||||
}
|
||||
|
||||
/// Parsed Rnote document, still in Rnote pixels.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct RnoteDocument {
|
||||
/// Page size in pixels and dpi
|
||||
pub format: DVec2,
|
||||
pub dpi: f64,
|
||||
/// Document bounds (upper left corner and size)
|
||||
pub origin: DVec2,
|
||||
pub size: DVec2,
|
||||
pub endless: bool,
|
||||
pub background: PageBackground,
|
||||
/// In drawing order
|
||||
pub items: Vec<RnoteItem>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum RnoteItem {
|
||||
Ink {
|
||||
color: [f32; 4],
|
||||
highlighter: bool,
|
||||
points: Vec<DVec2>,
|
||||
/// Drawn width per point
|
||||
widths: Vec<f64>,
|
||||
},
|
||||
Text {
|
||||
content: String,
|
||||
size: f64,
|
||||
mono: bool,
|
||||
/// Upper left corner and axes
|
||||
affine: [DVec2; 3],
|
||||
max_width: Option<f64>,
|
||||
color: [f32; 4],
|
||||
},
|
||||
Bitmap {
|
||||
width: u32,
|
||||
height: u32,
|
||||
/// Straight (not premultiplied) RGBA
|
||||
pixels: Vec<u8>,
|
||||
corners: [DVec2; 4],
|
||||
},
|
||||
/// SVG image (Rnote stores imported PDF pages like this), rasterized on import
|
||||
Svg { svg: String, corners: [DVec2; 4] },
|
||||
/// Element without a counterpart or unreadable
|
||||
Unsupported,
|
||||
}
|
||||
|
||||
impl RnoteItem {
|
||||
/// Point that decides which page the element belongs to.
|
||||
fn anchor(&self) -> Option<DVec2> {
|
||||
match self {
|
||||
RnoteItem::Ink { points, .. } => {
|
||||
let (min, max) = points.iter().fold(
|
||||
(DVec2::splat(f64::MAX), DVec2::splat(f64::MIN)),
|
||||
|(a, b), &p| (a.min(p), b.max(p)),
|
||||
);
|
||||
(!points.is_empty()).then(|| (min + max) / 2.0)
|
||||
}
|
||||
RnoteItem::Text { affine, .. } => Some(affine[2]),
|
||||
RnoteItem::Bitmap { corners, .. } | RnoteItem::Svg { corners, .. } => {
|
||||
Some((corners[0] + corners[2]) / 2.0)
|
||||
}
|
||||
RnoteItem::Unsupported => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn num(value: &Value, key: &str) -> Option<f64> {
|
||||
value.get(key)?.as_f64().filter(|v| v.is_finite())
|
||||
}
|
||||
|
||||
/// Vectors are stored as `[x, y]` (older versions possibly as `{"x": .., "y": ..}`).
|
||||
fn vec2(value: &Value) -> Option<DVec2> {
|
||||
match value {
|
||||
Value::Array(a) if a.len() == 2 => Some(DVec2::new(a[0].as_f64()?, a[1].as_f64()?)),
|
||||
Value::Object(_) => Some(DVec2::new(num(value, "x")?, num(value, "y")?)),
|
||||
_ => None,
|
||||
}
|
||||
.filter(|v| v.is_finite())
|
||||
}
|
||||
|
||||
/// Affine transformation as x axis, y axis and translation. Since Rnote 0.15 `affine` holds
|
||||
/// the six values; before, `transform.affine` held a column-major 3×3 matrix.
|
||||
fn affine(value: &Value) -> Option<[DVec2; 3]> {
|
||||
let values = value
|
||||
.get("affine")
|
||||
.or_else(|| value.get("transform")?.get("affine"))?
|
||||
.as_array()?;
|
||||
let v: Vec<f64> = values.iter().filter_map(Value::as_f64).collect();
|
||||
let [a, b, c, d, e, f] = match v.len() {
|
||||
6 => [v[0], v[1], v[2], v[3], v[4], v[5]],
|
||||
9 => [v[0], v[1], v[3], v[4], v[6], v[7]],
|
||||
_ => return None,
|
||||
};
|
||||
Some([DVec2::new(a, b), DVec2::new(c, d), DVec2::new(e, f)])
|
||||
}
|
||||
|
||||
fn apply(affine: &[DVec2; 3], p: DVec2) -> DVec2 {
|
||||
affine[0] * p.x + affine[1] * p.y + affine[2]
|
||||
}
|
||||
|
||||
fn color(value: Option<&Value>) -> Option<[f32; 4]> {
|
||||
let value = value.filter(|v| v.is_object())?;
|
||||
let c = |k| num(value, k).unwrap_or(0.0).clamp(0.0, 1.0) as f32;
|
||||
Some([
|
||||
c("r"),
|
||||
c("g"),
|
||||
c("b"),
|
||||
num(value, "a").map_or(1.0, |a| a.clamp(0.0, 1.0) as f32),
|
||||
])
|
||||
}
|
||||
|
||||
/// The one variant of an externally tagged enum: `{"name": {...}}`.
|
||||
fn variant(value: &Value) -> Option<(&str, &Value)> {
|
||||
let object = value.as_object()?;
|
||||
let (name, inner) = object.iter().next()?;
|
||||
Some((name.as_str(), inner))
|
||||
}
|
||||
|
||||
/// Width for the pressure according to Rnote's pressure curve.
|
||||
fn curve_width(curve: &str, width: f64, pressure: f64) -> f64 {
|
||||
let p = pressure.clamp(0.0, 1.0);
|
||||
match curve {
|
||||
"const" => width,
|
||||
"sqrt" => width * p.sqrt(),
|
||||
"cbrt" => width * p.cbrt(),
|
||||
"pow2" => width * p.powi(2),
|
||||
"pow3" => width * p.powi(3),
|
||||
_ => width * p,
|
||||
}
|
||||
}
|
||||
|
||||
/// Style options (smooth, rough or textured).
|
||||
struct Style {
|
||||
width: f64,
|
||||
stroke_color: Option<[f32; 4]>,
|
||||
fill_color: Option<[f32; 4]>,
|
||||
pressure_curve: String,
|
||||
}
|
||||
|
||||
fn style(value: Option<&Value>) -> Option<Style> {
|
||||
let (kind, options) = variant(value?)?;
|
||||
let curve = if kind == "rough" {
|
||||
"const".to_string()
|
||||
} else {
|
||||
options
|
||||
.get("pressure_curve")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("linear")
|
||||
.to_string()
|
||||
};
|
||||
Some(Style {
|
||||
width: num(options, "stroke_width").unwrap_or(1.0).max(0.1),
|
||||
stroke_color: color(options.get("stroke_color")),
|
||||
fill_color: color(options.get("fill_color")),
|
||||
pressure_curve: curve,
|
||||
})
|
||||
}
|
||||
|
||||
/// Points along a Bézier curve (without its start), at most every 2 pixels; for a line
|
||||
/// just its end.
|
||||
fn bezier(start: DVec2, controls: &[DVec2], end: DVec2) -> Vec<DVec2> {
|
||||
if controls.is_empty() {
|
||||
return vec![end];
|
||||
}
|
||||
let length = controls
|
||||
.iter()
|
||||
.chain([&end])
|
||||
.fold((0.0, start), |(sum, prev), &p| (sum + prev.distance(p), p))
|
||||
.0;
|
||||
let steps = ((length / 2.0).ceil() as usize).clamp(1, 32);
|
||||
(1..=steps)
|
||||
.map(|i| {
|
||||
let t = i as f64 / steps as f64;
|
||||
let u = 1.0 - t;
|
||||
match controls {
|
||||
[cp] => start * u * u + *cp * 2.0 * u * t + end * t * t,
|
||||
[cp1, cp2] => {
|
||||
start * u * u * u
|
||||
+ *cp1 * 3.0 * u * u * t
|
||||
+ *cp2 * 3.0 * u * t * t
|
||||
+ end * t * t * t
|
||||
}
|
||||
_ => start + (end - start) * t,
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn element(value: &Value) -> Option<(DVec2, f64)> {
|
||||
Some((
|
||||
vec2(value.get("pos")?)?,
|
||||
num(value, "pressure").unwrap_or(0.5),
|
||||
))
|
||||
}
|
||||
|
||||
fn brushstroke(value: &Value, highlighter: bool) -> Option<RnoteItem> {
|
||||
let style = style(value.get("style"))?;
|
||||
let color = style.stroke_color?;
|
||||
let path = value.get("path")?;
|
||||
let (mut prev, mut prev_pressure) = element(path.get("start")?)?;
|
||||
let mut points = vec![prev];
|
||||
let mut pressures = vec![prev_pressure];
|
||||
for segment in path.get("segments")?.as_array()? {
|
||||
let Some((kind, seg)) = variant(segment) else {
|
||||
continue;
|
||||
};
|
||||
let Some((end, end_pressure)) = seg.get("end").and_then(element) else {
|
||||
continue;
|
||||
};
|
||||
let controls: Vec<DVec2> = match kind {
|
||||
"quadbezto" | "quadbez" => seg.get("cp").and_then(vec2).into_iter().collect(),
|
||||
"cubbezto" | "cubbez" => [seg.get("cp1"), seg.get("cp2")]
|
||||
.into_iter()
|
||||
.filter_map(|v| v.and_then(vec2))
|
||||
.collect(),
|
||||
_ => Vec::new(),
|
||||
};
|
||||
let new = bezier(prev, &controls, end);
|
||||
let n = new.len() as f64;
|
||||
for (i, p) in new.into_iter().enumerate() {
|
||||
let t = (i + 1) as f64 / n;
|
||||
points.push(p);
|
||||
pressures.push(prev_pressure + (end_pressure - prev_pressure) * t);
|
||||
}
|
||||
prev = end;
|
||||
prev_pressure = end_pressure;
|
||||
}
|
||||
let widths = pressures
|
||||
.iter()
|
||||
.map(|&p| curve_width(&style.pressure_curve, style.width, p))
|
||||
.collect();
|
||||
Some(RnoteItem::Ink {
|
||||
highlighter: highlighter || color[3] < 0.99,
|
||||
color,
|
||||
points,
|
||||
widths,
|
||||
})
|
||||
}
|
||||
|
||||
/// Outline of a shape as polylines.
|
||||
fn shape_lines(shape: &Value) -> Option<Vec<Vec<DVec2>>> {
|
||||
let (kind, s) = variant(shape)?;
|
||||
let v = |k: &str| s.get(k).and_then(vec2);
|
||||
let lines = match kind {
|
||||
"line" => vec![vec![v("start")?, v("end")?]],
|
||||
"arrow" => {
|
||||
let (start, tip) = (v("start")?, v("tip")?);
|
||||
let back = (start - tip).normalize_or_zero();
|
||||
let length = (start.distance(tip) * 0.4).min(20.0);
|
||||
let wing = |angle: f64| tip + DVec2::from_angle(angle).rotate(back) * length;
|
||||
vec![vec![start, tip], vec![wing(0.5), tip, wing(-0.5)]]
|
||||
}
|
||||
"rect" => {
|
||||
let half = vec2(s.get("cuboid")?.get("half_extents")?)?;
|
||||
let affine = affine(s)?;
|
||||
let corners = [
|
||||
(-1.0, -1.0),
|
||||
(1.0, -1.0),
|
||||
(1.0, 1.0),
|
||||
(-1.0, 1.0),
|
||||
(-1.0, -1.0),
|
||||
];
|
||||
vec![corners
|
||||
.iter()
|
||||
.map(|&(x, y)| apply(&affine, DVec2::new(x * half.x, y * half.y)))
|
||||
.collect()]
|
||||
}
|
||||
"ellipse" => {
|
||||
let radii = v("radii")?;
|
||||
let affine = affine(s)?;
|
||||
vec![(0..=64)
|
||||
.map(|i| {
|
||||
let a = i as f64 / 64.0 * std::f64::consts::TAU;
|
||||
apply(&affine, DVec2::new(a.cos() * radii.x, a.sin() * radii.y))
|
||||
})
|
||||
.collect()]
|
||||
}
|
||||
"quadbez" | "cubbez" => {
|
||||
let start = v("start")?;
|
||||
let controls: Vec<DVec2> = ["cp", "cp1", "cp2"].iter().filter_map(|k| v(k)).collect();
|
||||
let mut points = vec![start];
|
||||
points.extend(bezier(start, &controls, v("end")?));
|
||||
vec![points]
|
||||
}
|
||||
"polyline" | "polygon" => {
|
||||
let start = v("start")?;
|
||||
let mut points = vec![start];
|
||||
points.extend(s.get("path")?.as_array()?.iter().filter_map(vec2));
|
||||
if kind == "polygon" {
|
||||
points.push(start);
|
||||
}
|
||||
vec![points]
|
||||
}
|
||||
_ => return None,
|
||||
};
|
||||
Some(lines)
|
||||
}
|
||||
|
||||
fn shapestroke(value: &Value, highlighter: bool) -> Vec<RnoteItem> {
|
||||
let Some(Style {
|
||||
width,
|
||||
stroke_color,
|
||||
fill_color,
|
||||
..
|
||||
}) = style(value.get("style"))
|
||||
else {
|
||||
return vec![RnoteItem::Unsupported];
|
||||
};
|
||||
let (Some(color), Some(lines)) = (
|
||||
stroke_color.or(fill_color),
|
||||
value.get("shape").and_then(shape_lines),
|
||||
) else {
|
||||
return vec![RnoteItem::Unsupported];
|
||||
};
|
||||
lines
|
||||
.into_iter()
|
||||
.map(|points| RnoteItem::Ink {
|
||||
color,
|
||||
highlighter: highlighter || color[3] < 0.99,
|
||||
widths: vec![width; points.len()],
|
||||
points,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn textstroke(value: &Value) -> Option<RnoteItem> {
|
||||
let style = value.get("text_style")?;
|
||||
Some(RnoteItem::Text {
|
||||
content: value.get("text")?.as_str()?.to_string(),
|
||||
size: num(style, "font_size").unwrap_or(32.0).max(1.0),
|
||||
mono: style
|
||||
.get("font_family")
|
||||
.and_then(Value::as_str)
|
||||
.is_some_and(|f| f.to_ascii_lowercase().contains("mono")),
|
||||
affine: affine(value)?,
|
||||
max_width: num(style, "max_width").filter(|w| *w > 0.0),
|
||||
color: color(style.get("color")).unwrap_or([0.0, 0.0, 0.0, 1.0]),
|
||||
})
|
||||
}
|
||||
|
||||
fn bitmapimage(value: &Value) -> Option<RnoteItem> {
|
||||
let image = value.get("image")?;
|
||||
let width = num(image, "pixel_width")? as u32;
|
||||
let height = num(image, "pixel_height")? as u32;
|
||||
let mut pixels = base64::engine::general_purpose::STANDARD
|
||||
.decode(image.get("data")?.as_str()?)
|
||||
.ok()?;
|
||||
if pixels.len() != width as usize * height as usize * 4 {
|
||||
return None;
|
||||
}
|
||||
unpremultiply(&mut pixels);
|
||||
Some(RnoteItem::Bitmap {
|
||||
width,
|
||||
height,
|
||||
pixels,
|
||||
corners: rectangle_corners(value.get("rectangle")?)?,
|
||||
})
|
||||
}
|
||||
|
||||
fn vectorimage(value: &Value) -> Option<RnoteItem> {
|
||||
Some(RnoteItem::Svg {
|
||||
svg: value.get("svg_data")?.as_str()?.to_string(),
|
||||
corners: rectangle_corners(value.get("rectangle")?)?,
|
||||
})
|
||||
}
|
||||
|
||||
/// Corners (upper left, upper right, lower right, lower left) of an Rnote rectangle: a
|
||||
/// box around the origin, placed by its affine transformation.
|
||||
fn rectangle_corners(rectangle: &Value) -> Option<[DVec2; 4]> {
|
||||
let half = vec2(rectangle.get("cuboid")?.get("half_extents")?)?;
|
||||
let affine = affine(rectangle)?;
|
||||
Some(
|
||||
[(-1.0, -1.0), (1.0, -1.0), (1.0, 1.0), (-1.0, 1.0)]
|
||||
.map(|(x, y)| apply(&affine, DVec2::new(x * half.x, y * half.y))),
|
||||
)
|
||||
}
|
||||
|
||||
/// 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()?;
|
||||
Some((v.major, v.minor, v.patch))
|
||||
}
|
||||
|
||||
/// Parses the (decompressed) JSON of an .rnote file.
|
||||
pub fn parse(json: &[u8]) -> Result<RnoteDocument, String> {
|
||||
let not_rnote = || t!("Keine Rnote-Datei").to_string();
|
||||
let wrapper: Value = serde_json::from_slice(json).map_err(|_| not_rnote())?;
|
||||
let version_text = wrapper
|
||||
.get("version")
|
||||
.and_then(Value::as_str)
|
||||
.ok_or_else(not_rnote)?;
|
||||
if version(version_text).is_none_or(|v| v < (0, 5, 10)) {
|
||||
return Err(t!(
|
||||
"Rnote-Datei zu alt ({version}): bitte in einem aktuellen Rnote öffnen und neu speichern",
|
||||
version = version_text
|
||||
)
|
||||
.to_string());
|
||||
}
|
||||
let snapshot = wrapper
|
||||
.get("data")
|
||||
.and_then(|d| d.get("engine_snapshot"))
|
||||
.ok_or_else(not_rnote)?;
|
||||
let document = snapshot.get("document").ok_or_else(not_rnote)?;
|
||||
// Since Rnote 0.13 format, background and layout are in `config`
|
||||
let config = document.get("config").unwrap_or(document);
|
||||
let format = config.get("format").unwrap_or(&Value::Null);
|
||||
let dpi = num(format, "dpi").filter(|d| *d > 0.0).unwrap_or(96.0);
|
||||
let format_size = DVec2::new(
|
||||
num(format, "width").unwrap_or(dpi * 210.0 / 25.4).max(1.0),
|
||||
num(format, "height").unwrap_or(dpi * 297.0 / 25.4).max(1.0),
|
||||
);
|
||||
let background = match config
|
||||
.get("background")
|
||||
.and_then(|b| b.get("pattern"))
|
||||
.and_then(Value::as_str)
|
||||
{
|
||||
Some("lines") => PageBackground::Lined,
|
||||
Some("grid" | "isometric_grid") => PageBackground::Grid5mm,
|
||||
Some("dots" | "isometric_dots") => PageBackground::DotGrid,
|
||||
_ => PageBackground::Blank,
|
||||
};
|
||||
let endless = matches!(
|
||||
config.get("layout").and_then(Value::as_str),
|
||||
Some("infinite" | "semi_infinite")
|
||||
);
|
||||
|
||||
let strokes = snapshot
|
||||
.get("stroke_components")
|
||||
.and_then(Value::as_array)
|
||||
.ok_or_else(not_rnote)?;
|
||||
let chrono = snapshot
|
||||
.get("chrono_components")
|
||||
.and_then(Value::as_array)
|
||||
.map(Vec::as_slice)
|
||||
.unwrap_or_default();
|
||||
// Slot maps are stored as slot arrays; the chrono components share the slot index
|
||||
let mut ordered: Vec<(u64, Vec<RnoteItem>)> = Vec::new();
|
||||
for (index, slot) in strokes.iter().enumerate() {
|
||||
let Some(stroke) = slot.get("value").filter(|v| !v.is_null()) else {
|
||||
continue;
|
||||
};
|
||||
let chrono = chrono.get(index).and_then(|c| c.get("value"));
|
||||
let t = chrono.and_then(|c| c.get("t")).and_then(Value::as_u64);
|
||||
let highlighter = chrono
|
||||
.and_then(|c| c.get("layer"))
|
||||
.and_then(Value::as_str)
|
||||
.is_some_and(|l| l.eq_ignore_ascii_case("highlighter"));
|
||||
let items = match variant(stroke) {
|
||||
Some(("brushstroke", v)) => {
|
||||
vec![brushstroke(v, highlighter).unwrap_or(RnoteItem::Unsupported)]
|
||||
}
|
||||
Some(("shapestroke", v)) => shapestroke(v, highlighter),
|
||||
Some(("textstroke", v)) => vec![textstroke(v).unwrap_or(RnoteItem::Unsupported)],
|
||||
Some(("bitmapimage", v)) => vec![bitmapimage(v).unwrap_or(RnoteItem::Unsupported)],
|
||||
Some(("vectorimage", v)) => vec![vectorimage(v).unwrap_or(RnoteItem::Unsupported)],
|
||||
_ => vec![RnoteItem::Unsupported],
|
||||
};
|
||||
ordered.push((t.unwrap_or(index as u64), items));
|
||||
}
|
||||
ordered.sort_by_key(|(t, _)| *t);
|
||||
|
||||
Ok(RnoteDocument {
|
||||
format: format_size,
|
||||
dpi,
|
||||
origin: DVec2::new(
|
||||
num(document, "x").unwrap_or(0.0),
|
||||
num(document, "y").unwrap_or(0.0),
|
||||
),
|
||||
size: DVec2::new(
|
||||
num(document, "width").unwrap_or(format_size.x),
|
||||
num(document, "height").unwrap_or(format_size.y),
|
||||
),
|
||||
endless,
|
||||
background,
|
||||
items: ordered.into_iter().flat_map(|(_, items)| items).collect(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Creates a new notebook from an Rnote file.
|
||||
pub fn create_notebook_from_rnote(
|
||||
base_dir: &Path,
|
||||
title: &str,
|
||||
path: &Path,
|
||||
) -> Result<(Notebook, NotebookManifest, std::path::PathBuf, ImportReport), String> {
|
||||
let bytes = std::fs::read(path).map_err(|e| e.to_string())?;
|
||||
let doc = parse(&import::gunzip(&bytes)?)?;
|
||||
import::create_notebook(base_dir, title, |dir| Ok(convert(&doc, dir)))
|
||||
}
|
||||
|
||||
/// A target page: where its Rnote page starts, and how Rnote pixels map onto it.
|
||||
struct Target {
|
||||
rnote_origin: DVec2,
|
||||
origin: glam::Vec2,
|
||||
scale: f32,
|
||||
size: glam::Vec2,
|
||||
strokes: Vec<StrokeData>,
|
||||
}
|
||||
|
||||
impl Target {
|
||||
fn map(&self, p: DVec2) -> glam::Vec2 {
|
||||
self.origin + (p - self.rnote_origin).as_vec2() * self.scale
|
||||
}
|
||||
}
|
||||
|
||||
fn convert(doc: &RnoteDocument, notebook_dir: &Path) -> Imported {
|
||||
let assets = crate::assets::AssetStore::new(notebook_dir);
|
||||
let mut report = ImportReport::default();
|
||||
// Page units per Rnote pixel at the document's dpi
|
||||
let units_per_px = (crate::document::UNITS_PER_MM as f64 * 25.4 / doc.dpi) as f32;
|
||||
|
||||
let (columns, mut targets) = if doc.endless {
|
||||
let target = Target {
|
||||
rnote_origin: DVec2::ZERO,
|
||||
origin: crate::document::ENDLESS_HOME,
|
||||
scale: units_per_px,
|
||||
size: crate::document::ENDLESS_PAGE_SIZE,
|
||||
strokes: Vec::new(),
|
||||
};
|
||||
(1, vec![target])
|
||||
} else {
|
||||
// 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_pt = (doc.format / doc.dpi * 72.0).as_vec2();
|
||||
let size = crate::document::pdf_page_size(size_pt.x, size_pt.y);
|
||||
let [origin, top_right, _, bottom_left] =
|
||||
crate::document::pdf_placement(size_pt.x, size_pt.y, size);
|
||||
let scale_pt =
|
||||
((top_right.x - origin.x) / size_pt.x).min((bottom_left.y - origin.y) / size_pt.y);
|
||||
let scale = scale_pt * (72.0 / doc.dpi) as f32;
|
||||
// Rnote grows documents page by page; tolerate rounding in the stored size
|
||||
let count = |length: f64, page: f64| ((length / page - 0.01).ceil() as usize).max(1);
|
||||
let columns = count(doc.size.x, doc.format.x);
|
||||
let rows = count(doc.size.y, doc.format.y);
|
||||
let rows = rows.min((MAX_PAGES / columns).max(1));
|
||||
let targets = (0..rows * columns)
|
||||
.map(|i| Target {
|
||||
rnote_origin: doc.origin
|
||||
+ DVec2::new((i % columns) as f64, (i / columns) as f64) * doc.format,
|
||||
origin,
|
||||
scale,
|
||||
size,
|
||||
strokes: Vec::new(),
|
||||
})
|
||||
.collect();
|
||||
(columns, targets)
|
||||
};
|
||||
let rows = targets.len() / columns;
|
||||
|
||||
let mut next_id: u64 = 1;
|
||||
for item in &doc.items {
|
||||
let Some(anchor) = item.anchor() else {
|
||||
report.skipped += 1;
|
||||
continue;
|
||||
};
|
||||
let cell = ((anchor - doc.origin) / doc.format).floor();
|
||||
let index = if doc.endless {
|
||||
0
|
||||
} else {
|
||||
let column = (cell.x.max(0.0) as usize).min(columns - 1);
|
||||
let row = (cell.y.max(0.0) as usize).min(rows - 1);
|
||||
row * columns + column
|
||||
};
|
||||
let target = &mut targets[index];
|
||||
let id = next_id;
|
||||
next_id += 1;
|
||||
let stroke = match item {
|
||||
RnoteItem::Ink {
|
||||
color,
|
||||
highlighter,
|
||||
points,
|
||||
widths,
|
||||
} => {
|
||||
let points: Vec<glam::Vec2> = points.iter().map(|&p| target.map(p)).collect();
|
||||
let widths: Vec<f32> = widths.iter().map(|&w| w as f32 * target.scale).collect();
|
||||
let (width, pressures) = import::fit_widths(&widths);
|
||||
import::ink_stroke(id, *color, *highlighter, &points, width, &pressures)
|
||||
}
|
||||
RnoteItem::Text {
|
||||
content,
|
||||
size,
|
||||
mono,
|
||||
affine,
|
||||
max_width,
|
||||
color,
|
||||
} => {
|
||||
let font = if *mono {
|
||||
TextFont::Mono
|
||||
} else {
|
||||
TextFont::Sans
|
||||
};
|
||||
import::text_stroke(
|
||||
id,
|
||||
content,
|
||||
*size as f32 * target.scale,
|
||||
font,
|
||||
target.map(affine[2]),
|
||||
[affine[0].as_vec2(), affine[1].as_vec2()],
|
||||
max_width.map(|w| w as f32 * target.scale),
|
||||
*color,
|
||||
)
|
||||
}
|
||||
RnoteItem::Bitmap {
|
||||
width,
|
||||
height,
|
||||
pixels,
|
||||
corners,
|
||||
} => match assets.import_rgba(*width, *height, pixels.clone()) {
|
||||
Ok(image) => import::image_stroke(id, image.asset, corners.map(|c| target.map(c))),
|
||||
Err(_) => {
|
||||
report.skipped += 1;
|
||||
continue;
|
||||
}
|
||||
},
|
||||
RnoteItem::Svg { svg, corners } => {
|
||||
let corners = corners.map(|c| target.map(c));
|
||||
let size = glam::Vec2::new(
|
||||
corners[0].distance(corners[1]),
|
||||
corners[0].distance(corners[3]),
|
||||
);
|
||||
let image = rasterize_svg(svg, size).and_then(|(width, height, pixels)| {
|
||||
assets.import_rgba(width, height, pixels).ok()
|
||||
});
|
||||
match image {
|
||||
Some(image) => import::image_stroke(id, image.asset, corners),
|
||||
None => {
|
||||
report.skipped += 1;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
RnoteItem::Unsupported => unreachable!("has no anchor"),
|
||||
};
|
||||
target.strokes.push(stroke);
|
||||
}
|
||||
|
||||
let pages = targets
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
.map(|(i, target)| {
|
||||
import::page(
|
||||
i as u64 + 1,
|
||||
target.size,
|
||||
doc.background,
|
||||
None,
|
||||
target.strokes,
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
Imported {
|
||||
pages,
|
||||
next_stroke_id: next_id,
|
||||
layout: doc.endless.then_some(NotebookLayout::Endless),
|
||||
report,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::document::StrokeTool;
|
||||
use serde_json::json;
|
||||
use std::io::Write;
|
||||
|
||||
fn slot(value: Value) -> Value {
|
||||
json!({ "value": value, "version": 1 })
|
||||
}
|
||||
|
||||
/// A small Rnote 0.15 document: two A4 pages at 96 dpi.
|
||||
fn sample(layout: &str) -> Value {
|
||||
let brush = json!({ "brushstroke": {
|
||||
"path": {
|
||||
"start": { "pos": [100.0, 100.0], "pressure": 0.5 },
|
||||
"segments": [
|
||||
{ "lineto": { "end": { "pos": [120.0, 100.0], "pressure": 1.0 } } },
|
||||
{ "cubbezto": { "cp1": [130.0, 110.0], "cp2": [140.0, 110.0],
|
||||
"end": { "pos": [150.0, 100.0], "pressure": 0.25 } } }
|
||||
]
|
||||
},
|
||||
"style": { "smooth": { "stroke_width": 4.0,
|
||||
"stroke_color": { "r": 0.0, "g": 0.0, "b": 1.0, "a": 1.0 },
|
||||
"fill_color": null, "pressure_curve": "linear" } }
|
||||
}});
|
||||
let rect = json!({ "shapestroke": {
|
||||
"shape": { "rect": { "cuboid": { "half_extents": [50.0, 20.0] },
|
||||
"affine": [1.0, 0.0, 0.0, 1.0, 300.0, 1300.0] } },
|
||||
"style": { "rough": { "stroke_width": 2.0,
|
||||
"stroke_color": { "r": 1.0, "g": 0.0, "b": 0.0, "a": 1.0 } } }
|
||||
}});
|
||||
let text = json!({ "textstroke": {
|
||||
"text": "Hallo",
|
||||
"affine": [1.0, 0.0, 0.0, 1.0, 50.0, 60.0],
|
||||
"text_style": { "font_family": "serif", "font_size": 32.0, "max_width": null,
|
||||
"color": { "r": 0.0, "g": 0.0, "b": 0.0, "a": 1.0 } }
|
||||
}});
|
||||
let pixels = base64::engine::general_purpose::STANDARD.encode([128u8, 0, 0, 128].repeat(4));
|
||||
let bitmap = json!({ "bitmapimage": {
|
||||
"image": { "data": pixels, "pixel_width": 2, "pixel_height": 2,
|
||||
"memory_format": "R8g8b8a8Premultiplied" },
|
||||
"rectangle": { "cuboid": { "half_extents": [10.0, 10.0] },
|
||||
"affine": [1.0, 0.0, 0.0, 1.0, 400.0, 400.0] }
|
||||
}});
|
||||
let svg = json!({ "vectorimage": {
|
||||
"svg_data": "<svg xmlns='http://www.w3.org/2000/svg' width='10' height='10'><rect width='10' height='10' fill='green'/></svg>",
|
||||
"intrinsic_size": [10.0, 10.0],
|
||||
"rectangle": { "cuboid": { "half_extents": [40.0, 40.0] },
|
||||
"affine": [1.0, 0.0, 0.0, 1.0, 400.0, 1500.0] }
|
||||
}});
|
||||
let marker = json!({ "brushstroke": {
|
||||
"path": { "start": { "pos": [10.0, 10.0], "pressure": 1.0 },
|
||||
"segments": [ { "lineto": { "end": { "pos": [90.0, 10.0], "pressure": 1.0 } } } ] },
|
||||
"style": { "smooth": { "stroke_width": 12.0,
|
||||
"stroke_color": { "r": 1.0, "g": 1.0, "b": 0.0, "a": 1.0 },
|
||||
"fill_color": null, "pressure_curve": "const" } }
|
||||
}});
|
||||
let chrono = |t: u32, layer: &str| slot(json!({ "t": t, "layer": layer }));
|
||||
json!({
|
||||
"version": "0.15.0",
|
||||
"data": { "engine_snapshot": {
|
||||
"document": {
|
||||
"config": {
|
||||
"format": { "width": 793.701, "height": 1122.52, "dpi": 96.0 },
|
||||
"background": { "pattern": "grid" },
|
||||
"layout": layout
|
||||
},
|
||||
"x": 0.0, "y": 0.0, "width": 793.701, "height": 2245.04
|
||||
},
|
||||
"camera": {},
|
||||
// Slot 0 is the slot map's unused sentinel; the text was drawn first
|
||||
"stroke_components": [
|
||||
{ "value": null, "version": 0 },
|
||||
slot(brush), slot(rect), slot(text), slot(bitmap), slot(svg), slot(marker)
|
||||
],
|
||||
"chrono_components": [
|
||||
{ "value": null, "version": 0 },
|
||||
chrono(2, "user_layer"), chrono(3, "user_layer"), chrono(1, "user_layer"),
|
||||
chrono(4, "image"), chrono(5, "image"), chrono(6, "highlighter")
|
||||
],
|
||||
"chrono_counter": 7
|
||||
}}
|
||||
})
|
||||
}
|
||||
|
||||
fn write_rnote(dir: &Path, value: &Value) -> std::path::PathBuf {
|
||||
let path = dir.join("Skizze.rnote");
|
||||
let mut encoder = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::default());
|
||||
encoder.write_all(value.to_string().as_bytes()).unwrap();
|
||||
std::fs::write(&path, encoder.finish().unwrap()).unwrap();
|
||||
path
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_rnote() {
|
||||
let doc = parse(sample("fixed_size").to_string().as_bytes()).unwrap();
|
||||
assert_eq!(doc.background, PageBackground::Grid5mm);
|
||||
assert!(!doc.endless);
|
||||
// In drawing order: the text was drawn first
|
||||
assert!(matches!(doc.items[0], RnoteItem::Text { .. }));
|
||||
match &doc.items[1] {
|
||||
RnoteItem::Ink {
|
||||
points,
|
||||
widths,
|
||||
color,
|
||||
highlighter,
|
||||
} => {
|
||||
assert!(!highlighter);
|
||||
assert_eq!(*color, [0.0, 0.0, 1.0, 1.0]);
|
||||
assert_eq!(points.first(), Some(&DVec2::new(100.0, 100.0)));
|
||||
assert_eq!(points.last(), Some(&DVec2::new(150.0, 100.0)));
|
||||
assert!(points.len() > 3, "the curve is sampled");
|
||||
assert_eq!(widths[0], 2.0);
|
||||
assert_eq!(widths[1], 4.0);
|
||||
assert_eq!(*widths.last().unwrap(), 1.0);
|
||||
}
|
||||
other => panic!("expected ink, got {other:?}"),
|
||||
}
|
||||
match &doc.items[2] {
|
||||
RnoteItem::Ink { points, .. } => {
|
||||
assert_eq!(points.len(), 5);
|
||||
assert_eq!(points[0], DVec2::new(250.0, 1280.0));
|
||||
assert_eq!(points[2], DVec2::new(350.0, 1320.0));
|
||||
}
|
||||
other => panic!("expected the rectangle, got {other:?}"),
|
||||
}
|
||||
match &doc.items[3] {
|
||||
RnoteItem::Bitmap { pixels, .. } => assert_eq!(&pixels[..4], &[255, 0, 0, 128]),
|
||||
other => panic!("expected a bitmap, got {other:?}"),
|
||||
}
|
||||
assert!(matches!(doc.items[4], RnoteItem::Svg { .. }));
|
||||
assert!(matches!(
|
||||
doc.items[5],
|
||||
RnoteItem::Ink {
|
||||
highlighter: true,
|
||||
..
|
||||
}
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_older_formats() {
|
||||
// Before 0.13: format, background and layout directly in the document; before 0.15:
|
||||
// transformations as column-major 3×3 matrix
|
||||
let mut value = sample("continuous_vertical");
|
||||
value["version"] = json!("0.9.4");
|
||||
let document = &mut value["data"]["engine_snapshot"]["document"];
|
||||
let config = document["config"].take();
|
||||
for key in ["format", "background", "layout"] {
|
||||
document[key] = config[key].clone();
|
||||
}
|
||||
document.as_object_mut().unwrap().remove("config");
|
||||
let text =
|
||||
&mut value["data"]["engine_snapshot"]["stroke_components"][3]["value"]["textstroke"];
|
||||
text.as_object_mut().unwrap().remove("affine");
|
||||
text["transform"] = json!({ "affine": [1.0, 0.0, 0.0, 0.0, 1.0, 0.0, 50.0, 60.0, 1.0] });
|
||||
let doc = parse(value.to_string().as_bytes()).unwrap();
|
||||
assert_eq!(doc.background, PageBackground::Grid5mm);
|
||||
match &doc.items[0] {
|
||||
RnoteItem::Text { affine, .. } => assert_eq!(affine[2], DVec2::new(50.0, 60.0)),
|
||||
other => panic!("expected a text, got {other:?}"),
|
||||
}
|
||||
|
||||
value["version"] = json!("0.5.5");
|
||||
assert!(parse(value.to_string().as_bytes()).is_err());
|
||||
assert!(parse(b"{\"a\": 1}").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_create_notebook_from_rnote() {
|
||||
let temp_dir = tempfile::tempdir().unwrap();
|
||||
let source_dir = tempfile::tempdir().unwrap();
|
||||
let path = write_rnote(source_dir.path(), &sample("fixed_size"));
|
||||
let (notebook, manifest, dir, report) =
|
||||
create_notebook_from_rnote(temp_dir.path(), "Skizze", &path).unwrap();
|
||||
assert_eq!(report, ImportReport::default());
|
||||
assert_eq!(manifest.layout, NotebookManifest::new("x", "x").layout);
|
||||
|
||||
let (mut reloaded, _) = crate::storage::load_notebook(&dir).unwrap();
|
||||
crate::storage::load_all_pages(&dir, &mut reloaded).unwrap();
|
||||
assert_eq!(notebook.pages.len(), 2);
|
||||
let [first, second] = &reloaded.pages[..] else {
|
||||
panic!("two pages expected");
|
||||
};
|
||||
assert_eq!(first.background, PageBackground::Grid5mm);
|
||||
// Text, pen, bitmap, highlighter on page 1; rectangle and SVG on page 2
|
||||
assert_eq!(first.strokes.len(), 4);
|
||||
assert_eq!(second.strokes.len(), 2);
|
||||
assert!(first.strokes[0].is_text());
|
||||
let pen = &first.strokes[1];
|
||||
assert_eq!(pen.tool, StrokeTool::Pen);
|
||||
// 96 dpi: 100 px = 26.46 mm
|
||||
assert!((pen.points[0].position.x - 264.6).abs() < 0.5);
|
||||
assert!(first.strokes[2].is_image());
|
||||
assert_eq!(first.strokes[3].tool, StrokeTool::Highlighter);
|
||||
// The rectangle sits on page 2 in page coordinates: 250 px - 0 / 1280 px - 1122.52 px
|
||||
let rect = &second.strokes[0];
|
||||
assert!((rect.points[0].position.x - 661.5).abs() < 0.5);
|
||||
assert!((rect.points[0].position.y - 416.7).abs() < 0.5);
|
||||
assert!(second.strokes[1].is_image());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_infinite_documents_become_endless() {
|
||||
let temp_dir = tempfile::tempdir().unwrap();
|
||||
let source_dir = tempfile::tempdir().unwrap();
|
||||
let path = write_rnote(source_dir.path(), &sample("infinite"));
|
||||
let (notebook, manifest, _, _) =
|
||||
create_notebook_from_rnote(temp_dir.path(), "Skizze", &path).unwrap();
|
||||
assert_eq!(manifest.layout, Some(NotebookLayout::Endless));
|
||||
assert_eq!(notebook.pages.len(), 1);
|
||||
assert!(notebook.pages[0].is_endless());
|
||||
assert_eq!(notebook.pages[0].strokes.len(), 6);
|
||||
let pen = ¬ebook.pages[0].strokes[1];
|
||||
let home = crate::document::ENDLESS_HOME;
|
||||
assert!((pen.points[0].position.x - home.x - 264.6).abs() < 0.5);
|
||||
}
|
||||
}
|
||||
@@ -669,7 +669,7 @@ impl UiState {
|
||||
self.open_file_picker(PickerKind::Image);
|
||||
}
|
||||
|
||||
/// Opens the file picker "Import" (PDF, Xournal++) in the last used folder.
|
||||
/// Opens the file picker "Import" (PDF, Xournal++, Rnote) in the last used folder.
|
||||
pub fn open_import_picker(&mut self) {
|
||||
self.open_file_picker(PickerKind::Import);
|
||||
}
|
||||
@@ -2831,11 +2831,12 @@ fn file_picker_dialog(ctx: &egui::Context, state: &mut UiState, actions: &mut Ve
|
||||
t!("Importieren"),
|
||||
t!("📄 Importieren"),
|
||||
"📄",
|
||||
t!("Keine Ordner, PDF- oder Xournal++-Dateien"),
|
||||
t!("Keine Ordner, PDF-, Xournal++- oder Rnote-Dateien"),
|
||||
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): Striche, Texte, Bilder und PDF-Hintergründe werden übernommen."
|
||||
Xournal++ (.xopp, .xoj) und Rnote (.rnote): Striche, Formen, Texte und Bilder \
|
||||
werden übernommen, bei Xournal++ auch PDF-Hintergründe."
|
||||
),
|
||||
),
|
||||
};
|
||||
@@ -4196,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++) …")))
|
||||
.on_hover_text(t!("Vorlesungsfolien, Übungsblätter, Formulare oder Xournal++-Notizbücher als neues Notizbuch öffnen und darauf schreiben"))
|
||||
.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"))
|
||||
.clicked()
|
||||
{
|
||||
state.open_import_picker();
|
||||
|
||||
+46
-159
@@ -7,23 +7,17 @@
|
||||
//! (`<teximage>`) have no counterpart and are skipped.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::io::Read;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use base64::Engine;
|
||||
use quick_xml::events::{BytesStart, Event};
|
||||
use quick_xml::XmlVersion;
|
||||
|
||||
use crate::document::{Notebook, Page, PageBackground, PdfBackground, StrokeTool};
|
||||
use crate::storage::{self, NotebookManifest, SampleData, StrokeData};
|
||||
use crate::document::{Notebook, PageBackground, PdfBackground};
|
||||
use crate::import::{self, ImportReport, Imported};
|
||||
use crate::storage::{NotebookManifest, StrokeData};
|
||||
use crate::t;
|
||||
use crate::text::{TextBox, TextFont};
|
||||
|
||||
/// Largest accepted file after decompression (guards against gzip bombs).
|
||||
const MAX_XML_BYTES: u64 = 512 * 1024 * 1024;
|
||||
|
||||
/// Alpha of imported highlighter strokes that are stored opaque (as the app's highlighters).
|
||||
const HIGHLIGHTER_ALPHA: f32 = 0.35;
|
||||
use crate::text::TextFont;
|
||||
|
||||
/// Is the file by its name a Xournal++ or Xournal notebook?
|
||||
pub fn is_xopp_path(path: &Path) -> bool {
|
||||
@@ -86,26 +80,9 @@ pub enum XoppItem {
|
||||
Unsupported,
|
||||
}
|
||||
|
||||
/// What could not be taken over.
|
||||
#[derive(Debug, Clone, Default, PartialEq)]
|
||||
pub struct ImportReport {
|
||||
pub skipped: usize,
|
||||
/// The PDF background was referenced but not found or unreadable
|
||||
pub missing_pdf: Option<String>,
|
||||
}
|
||||
|
||||
/// Reads the (usually gzip-compressed) XML of a Xournal file.
|
||||
pub fn decompress(bytes: &[u8]) -> Result<String, String> {
|
||||
let mut xml = Vec::new();
|
||||
if bytes.starts_with(&[0x1f, 0x8b]) {
|
||||
flate2::read::MultiGzDecoder::new(bytes)
|
||||
.take(MAX_XML_BYTES)
|
||||
.read_to_end(&mut xml)
|
||||
.map_err(|e| t!("Datei nicht lesbar: {err}", err = e))?;
|
||||
} else {
|
||||
xml = bytes.to_vec();
|
||||
}
|
||||
String::from_utf8(xml).map_err(|_| t!("Keine Xournal-Datei").to_string())
|
||||
String::from_utf8(import::gunzip(bytes)?).map_err(|_| t!("Keine Xournal-Datei").to_string())
|
||||
}
|
||||
|
||||
fn attributes(e: &BytesStart) -> HashMap<String, String> {
|
||||
@@ -326,24 +303,18 @@ fn finish_item(element: Open, content: &str) -> Option<XoppItem> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Sample pressures and our stroke width for Xournal widths: we draw a diameter of
|
||||
/// `width · (0.5 + p)`, so the widest point gets p = 1 and thinner ones less.
|
||||
/// Our stroke width and sample pressures for Xournal widths (nominal width, then optionally
|
||||
/// one width per point, or per segment in the original Xournal).
|
||||
fn stroke_widths(widths: &[f32], count: usize, scale: f32) -> (f32, Vec<f32>) {
|
||||
let nominal = widths.first().copied().unwrap_or(1.0).max(0.01) * scale;
|
||||
let per_point: Vec<f32> = widths.get(1..).unwrap_or_default().to_vec();
|
||||
// Xournal++ stores one width per point, the original Xournal one per segment
|
||||
let per_point = widths.get(1..).unwrap_or_default();
|
||||
if per_point.is_empty() || per_point.len() + 1 < count {
|
||||
return (nominal, vec![0.5; count]);
|
||||
}
|
||||
let widest = per_point.iter().copied().fold(0.0, f32::max).max(0.01) * scale;
|
||||
let width = widest / 1.5;
|
||||
let pressures = (0..count)
|
||||
.map(|i| {
|
||||
let w = per_point[i.min(per_point.len() - 1)] * scale;
|
||||
(w / width - 0.5).clamp(0.0, 1.0)
|
||||
})
|
||||
let scaled: Vec<f32> = (0..count)
|
||||
.map(|i| per_point[i.min(per_point.len() - 1)] * scale)
|
||||
.collect();
|
||||
(width, pressures)
|
||||
import::fit_widths(&scaled)
|
||||
}
|
||||
|
||||
/// Finds a referenced file: as given, next to the Xournal file, or as Xournal++ attachment
|
||||
@@ -370,23 +341,7 @@ pub fn create_notebook_from_xopp(
|
||||
if doc.pages.is_empty() {
|
||||
return Err(t!("Die Datei enthält keine Seiten").to_string());
|
||||
}
|
||||
let (_, _, dir) = storage::create_new_notebook(base_dir, title).map_err(|e| e.to_string())?;
|
||||
let result = (|| {
|
||||
let (pages, next_stroke_id, report) = convert(&doc, xopp_path, &dir)?;
|
||||
let page_count = pages.len() as u64;
|
||||
let notebook = Notebook::from_pages(pages, 0, next_stroke_id, page_count + 1);
|
||||
let mut manifest = storage::load_manifest(&dir).map_err(|e| e.to_string())?;
|
||||
storage::save_notebook(&dir, ¬ebook, &mut manifest).map_err(|e| e.to_string())?;
|
||||
Ok((notebook, manifest, report))
|
||||
})();
|
||||
match result {
|
||||
Ok((notebook, manifest, report)) => Ok((notebook, manifest, dir, report)),
|
||||
Err(err) => {
|
||||
// Don't leave a half-finished notebook behind
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
Err(err)
|
||||
}
|
||||
}
|
||||
import::create_notebook(base_dir, title, |dir| convert(&doc, xopp_path, dir))
|
||||
}
|
||||
|
||||
/// PDF used as background: asset name and page sizes in points.
|
||||
@@ -395,11 +350,7 @@ struct PdfSource {
|
||||
sizes: Vec<(f32, f32)>,
|
||||
}
|
||||
|
||||
fn convert(
|
||||
doc: &XoppDocument,
|
||||
xopp_path: &Path,
|
||||
notebook_dir: &Path,
|
||||
) -> Result<(Vec<Page>, u64, ImportReport), String> {
|
||||
fn convert(doc: &XoppDocument, xopp_path: &Path, notebook_dir: &Path) -> Result<Imported, String> {
|
||||
let assets = crate::assets::AssetStore::new(notebook_dir);
|
||||
let mut report = ImportReport::default();
|
||||
let mut pdfs: HashMap<String, Option<PdfSource>> = HashMap::new();
|
||||
@@ -458,7 +409,9 @@ fn convert(
|
||||
.and_then(|p| std::fs::read(p).ok())
|
||||
.and_then(|b| assets.import_bytes(&b).ok());
|
||||
match image {
|
||||
Some(image) => strokes.push(image_data(new_id(), image.asset, placement)),
|
||||
Some(image) => {
|
||||
strokes.push(import::image_stroke(new_id(), image.asset, placement))
|
||||
}
|
||||
None => report.skipped += 1,
|
||||
}
|
||||
}
|
||||
@@ -473,42 +426,15 @@ fn convert(
|
||||
points,
|
||||
} => {
|
||||
let (width, pressures) = stroke_widths(widths, points.len(), scale);
|
||||
let mut color = *color;
|
||||
let tool = if *highlighter {
|
||||
if color[3] >= 0.99 {
|
||||
color[3] = HIGHLIGHTER_ALPHA;
|
||||
}
|
||||
StrokeTool::Highlighter
|
||||
} else {
|
||||
// Pens are always opaque (translucent pens are read as highlighters)
|
||||
color[3] = 1.0;
|
||||
StrokeTool::Pen
|
||||
};
|
||||
let samples = points
|
||||
.iter()
|
||||
.zip(pressures)
|
||||
.enumerate()
|
||||
.map(|(i, (&p, pressure))| {
|
||||
let p = map(p);
|
||||
SampleData {
|
||||
x: p.x,
|
||||
y: p.y,
|
||||
p: pressure,
|
||||
t: i as f64 * 0.005,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
strokes.push(StrokeData {
|
||||
id: new_id(),
|
||||
color,
|
||||
samples,
|
||||
shape: None,
|
||||
tool,
|
||||
let points: Vec<glam::Vec2> = points.iter().map(|&p| map(p)).collect();
|
||||
strokes.push(import::ink_stroke(
|
||||
new_id(),
|
||||
*color,
|
||||
*highlighter,
|
||||
&points,
|
||||
width,
|
||||
finalized: true,
|
||||
image: None,
|
||||
text: None,
|
||||
});
|
||||
&pressures,
|
||||
));
|
||||
}
|
||||
XoppItem::Text {
|
||||
mono,
|
||||
@@ -522,34 +448,17 @@ fn convert(
|
||||
} else {
|
||||
TextFont::Sans
|
||||
};
|
||||
let size = size * scale;
|
||||
// Xournal doesn't wrap: as wide as the longest line
|
||||
let unwrapped = TextBox::new(content.clone(), size, font, f32::MAX / 4.0);
|
||||
let widest = unwrapped
|
||||
.layout()
|
||||
.lines
|
||||
.iter()
|
||||
.map(|line| line.width)
|
||||
.fold(0.0, f32::max);
|
||||
let text = TextBox::new(content.clone(), size, font, widest + size);
|
||||
let corners = crate::text::frame_corners(
|
||||
strokes.push(import::text_stroke(
|
||||
new_id(),
|
||||
content,
|
||||
size * scale,
|
||||
font,
|
||||
map(*pos),
|
||||
[glam::Vec2::X, glam::Vec2::Y],
|
||||
text.box_size(),
|
||||
);
|
||||
let mut color = *color;
|
||||
color[3] = 1.0;
|
||||
strokes.push(StrokeData {
|
||||
id: new_id(),
|
||||
color,
|
||||
samples: corner_samples(corners),
|
||||
shape: None,
|
||||
tool: StrokeTool::Pen,
|
||||
width: 0.0,
|
||||
finalized: true,
|
||||
image: None,
|
||||
text: Some(text),
|
||||
});
|
||||
None,
|
||||
*color,
|
||||
));
|
||||
}
|
||||
XoppItem::Image { rect, data } => {
|
||||
let compact: String =
|
||||
@@ -568,7 +477,7 @@ fn convert(
|
||||
glam::Vec2::new(l, b),
|
||||
]
|
||||
.map(map);
|
||||
strokes.push(image_data(new_id(), image.asset, corners));
|
||||
strokes.push(import::image_stroke(new_id(), image.asset, corners));
|
||||
}
|
||||
None => report.skipped += 1,
|
||||
}
|
||||
@@ -577,18 +486,20 @@ fn convert(
|
||||
}
|
||||
}
|
||||
|
||||
let page_data = storage::PageData {
|
||||
format_version: storage::CURRENT_FORMAT_VERSION,
|
||||
id: index as u64 + 1,
|
||||
pages.push(import::page(
|
||||
index as u64 + 1,
|
||||
size,
|
||||
background,
|
||||
pdf_background,
|
||||
title: None,
|
||||
size: (size != crate::document::A4_SIZE).then(|| size.to_array()),
|
||||
strokes,
|
||||
};
|
||||
pages.push(page_data.to_page());
|
||||
));
|
||||
}
|
||||
Ok((pages, next_id, report))
|
||||
Ok(Imported {
|
||||
pages,
|
||||
next_stroke_id: next_id,
|
||||
layout: None,
|
||||
report,
|
||||
})
|
||||
}
|
||||
|
||||
fn load_pdf(file: &str, xopp_path: &Path, assets: &crate::assets::AssetStore) -> Option<PdfSource> {
|
||||
@@ -598,35 +509,11 @@ fn load_pdf(file: &str, xopp_path: &Path, assets: &crate::assets::AssetStore) ->
|
||||
Some(PdfSource { asset, sizes })
|
||||
}
|
||||
|
||||
fn corner_samples(corners: [glam::Vec2; 4]) -> Vec<SampleData> {
|
||||
corners
|
||||
.iter()
|
||||
.map(|c| SampleData {
|
||||
x: c.x,
|
||||
y: c.y,
|
||||
p: 1.0,
|
||||
t: 0.0,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn image_data(id: u64, asset: String, corners: [glam::Vec2; 4]) -> StrokeData {
|
||||
StrokeData {
|
||||
id,
|
||||
color: [1.0, 1.0, 1.0, 1.0],
|
||||
samples: corner_samples(corners),
|
||||
shape: None,
|
||||
tool: StrokeTool::Pen,
|
||||
width: 0.0,
|
||||
finalized: true,
|
||||
image: Some(asset),
|
||||
text: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::document::StrokeTool;
|
||||
use crate::storage;
|
||||
use std::io::Write;
|
||||
|
||||
fn gzip(xml: &str) -> Vec<u8> {
|
||||
@@ -790,7 +677,7 @@ zweite Zeile</text>
|
||||
assert!(!pen.vertices.is_empty());
|
||||
let marker = &first.strokes[1];
|
||||
assert_eq!(marker.tool, StrokeTool::Highlighter);
|
||||
assert_eq!(marker.color[3], HIGHLIGHTER_ALPHA);
|
||||
assert_eq!(marker.color[3], import::HIGHLIGHTER_ALPHA);
|
||||
assert!(first.strokes[2].is_text());
|
||||
|
||||
for (i, page) in reloaded.pages[1..].iter().enumerate() {
|
||||
|
||||
Reference in new issue
Block a user