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
+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);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user