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