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:
haraldandClaude Opus 5.5 committed 2026-10-27 11:40:51 +01:00
1 parent 2bd697ef82
commit 46026eb8e5
14 files changed
+1567 -187

No files matched your search

+257
View File
@@ -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, &notebook, &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);
}
}