Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cbf699b680 | ||
|
|
ade20ab3dc | ||
|
|
f24e7750e6 | ||
|
|
097933882c | ||
|
|
b3578175c2 | ||
|
|
1cb02bf8e9 | ||
|
|
26c4191e0f |
Generated
+1
-1
@@ -1393,7 +1393,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ink-notes"
|
||||
version = "0.26.0"
|
||||
version = "0.26.2"
|
||||
dependencies = [
|
||||
"ab_glyph",
|
||||
"arboard",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "ink-notes"
|
||||
version = "0.26.0"
|
||||
version = "0.26.2"
|
||||
edition = "2021"
|
||||
description = "Notizbuch-App für Stift, Touch und Maus unter Linux"
|
||||
repository = "https://gitea.pansi.eu/harald/ink-notes"
|
||||
|
||||
@@ -61,12 +61,39 @@
|
||||
<url type="homepage">https://gitea.pansi.eu/harald/ink-notes</url>
|
||||
<url type="bugtracker">https://gitea.pansi.eu/harald/ink-notes/issues</url>
|
||||
<url type="vcs-browser">https://gitea.pansi.eu/harald/ink-notes</url>
|
||||
<screenshots>
|
||||
<screenshot type="default">
|
||||
<caption>Notes with highlighter, handwriting and text boxes</caption>
|
||||
<caption xml:lang="de">Notizen mit Textmarker, Handschrift und Textfeldern</caption>
|
||||
<image>https://gitea.pansi.eu/harald/ink-notes/raw/tag/v0.26.1/packaging/screenshots/notes.png</image>
|
||||
</screenshot>
|
||||
<screenshot>
|
||||
<caption>Recognized shapes: coordinate system, triangle, circle, octahedron</caption>
|
||||
<caption xml:lang="de">Erkannte Formen: Koordinatensystem, Dreieck, Kreis, Oktaeder</caption>
|
||||
<image>https://gitea.pansi.eu/harald/ink-notes/raw/tag/v0.26.1/packaging/screenshots/shapes.png</image>
|
||||
</screenshot>
|
||||
<screenshot>
|
||||
<caption>Annotating imported PDF slides</caption>
|
||||
<caption xml:lang="de">Importierte PDF-Folien beschriften</caption>
|
||||
<image>https://gitea.pansi.eu/harald/ink-notes/raw/tag/v0.26.1/packaging/screenshots/pdf.png</image>
|
||||
</screenshot>
|
||||
</screenshots>
|
||||
<branding>
|
||||
<color type="primary" scheme_preference="light">#a8c8f0</color>
|
||||
<color type="primary" scheme_preference="dark">#1f3a5f</color>
|
||||
</branding>
|
||||
<content_rating type="oars-1.1" />
|
||||
<releases>
|
||||
<release version="0.26.2" date="2026-10-13">
|
||||
<description>
|
||||
<p>Smoother scrolling through large imported PDFs: pages no longer stall the display while they are prepared, and pages scrolled past quickly are skipped.</p>
|
||||
</description>
|
||||
</release>
|
||||
<release version="0.26.1" date="2026-10-10">
|
||||
<description>
|
||||
<p>Screenshots in the app description for software centers. The app itself is unchanged.</p>
|
||||
</description>
|
||||
</release>
|
||||
<release version="0.26.0" date="2026-10-10">
|
||||
<description>
|
||||
<p>Select and move objects with a finger; text and make-space tools also work with a finger. Exports go to the documents folder of the user directories.</p>
|
||||
|
||||
@@ -50,6 +50,4 @@ modules:
|
||||
- target
|
||||
- .git
|
||||
- .flatpak-builder
|
||||
- ideas.txt
|
||||
- issue.txt
|
||||
- cargo-sources.json
|
||||
|
||||
@@ -61,12 +61,39 @@
|
||||
<url type="homepage">https://gitea.pansi.eu/harald/ink-notes</url>
|
||||
<url type="bugtracker">https://gitea.pansi.eu/harald/ink-notes/issues</url>
|
||||
<url type="vcs-browser">https://gitea.pansi.eu/harald/ink-notes</url>
|
||||
<screenshots>
|
||||
<screenshot type="default">
|
||||
<caption>Notes with highlighter, handwriting and text boxes</caption>
|
||||
<caption xml:lang="de">Notizen mit Textmarker, Handschrift und Textfeldern</caption>
|
||||
<image>https://gitea.pansi.eu/harald/ink-notes/raw/tag/v0.26.1/packaging/screenshots/notes.png</image>
|
||||
</screenshot>
|
||||
<screenshot>
|
||||
<caption>Recognized shapes: coordinate system, triangle, circle, octahedron</caption>
|
||||
<caption xml:lang="de">Erkannte Formen: Koordinatensystem, Dreieck, Kreis, Oktaeder</caption>
|
||||
<image>https://gitea.pansi.eu/harald/ink-notes/raw/tag/v0.26.1/packaging/screenshots/shapes.png</image>
|
||||
</screenshot>
|
||||
<screenshot>
|
||||
<caption>Annotating imported PDF slides</caption>
|
||||
<caption xml:lang="de">Importierte PDF-Folien beschriften</caption>
|
||||
<image>https://gitea.pansi.eu/harald/ink-notes/raw/tag/v0.26.1/packaging/screenshots/pdf.png</image>
|
||||
</screenshot>
|
||||
</screenshots>
|
||||
<branding>
|
||||
<color type="primary" scheme_preference="light">#a8c8f0</color>
|
||||
<color type="primary" scheme_preference="dark">#1f3a5f</color>
|
||||
</branding>
|
||||
<content_rating type="oars-1.1" />
|
||||
<releases>
|
||||
<release version="0.26.2" date="2026-10-13">
|
||||
<description>
|
||||
<p>Smoother scrolling through large imported PDFs: pages no longer stall the display while they are prepared, and pages scrolled past quickly are skipped.</p>
|
||||
</description>
|
||||
</release>
|
||||
<release version="0.26.1" date="2026-10-10">
|
||||
<description>
|
||||
<p>Screenshots in the app description for software centers. The app itself is unchanged.</p>
|
||||
</description>
|
||||
</release>
|
||||
<release version="0.26.0" date="2026-10-10">
|
||||
<description>
|
||||
<p>Select and move objects with a finger; text and make-space tools also work with a finger. Exports go to the documents folder of the user directories.</p>
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
Notizbuch-App für Stift, Touch und Maus unter Linux (getestet mit Zorin OS 18 / Ubuntu 24.04).
|
||||
|
||||
### Highlights:
|
||||
- **Flüssigeres Scrollen in großen PDFs**: Neu gerenderte PDF-Seiten halten die Anzeige nicht mehr an, und Seiten, über die man schnell hinwegscrollt, werden übersprungen statt der Reihe nach gerendert
|
||||
- **Screenshots für Flathub**: Die App-Beschreibung (Metainfo) zeigt jetzt drei Screenshots; an der App selbst ändert sich nichts
|
||||
- **Auswählen mit dem Finger**: Im Auswahlwerkzeug wählt und verschiebt ein Finger Objekte (auf leerer Fläche scrollt er weiter); Text und Platz schaffen gehen ebenfalls mit dem Finger
|
||||
- **Flatpak**: Manifest unter packaging/flatpak (Vorbereitung für Flathub); Exporte landen im Dokumente-Ordner aus den Benutzerordnern (z. B. ~/Dokumente)
|
||||
- **Englische Oberfläche / English interface**: ink-notes folgt der Systemsprache (Deutsch oder Englisch); umstellen im ☰-Menü unter „Sprache / Language“
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 184 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 118 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 73 KiB |
+94
-27
@@ -45,26 +45,57 @@ pub fn quad_vertices(corners: &[glam::Vec2; 4]) -> [ImageVertex; 6] {
|
||||
[tl, tr, bl, bl, tr, br]
|
||||
}
|
||||
|
||||
/// Mipmap-Stufen eines Bildes, jeweils halbe Kantenlänge bis 1 × 1.
|
||||
pub fn mip_chain(base: &RgbaImage) -> Vec<RgbaImage> {
|
||||
let mut levels = vec![base.clone()];
|
||||
let Some(mut current) =
|
||||
image::RgbaImage::from_raw(base.width, base.height, base.pixels.clone())
|
||||
else {
|
||||
return levels;
|
||||
};
|
||||
while current.width() > 1 || current.height() > 1 {
|
||||
let (w, h) = ((current.width() / 2).max(1), (current.height() / 2).max(1));
|
||||
current = image::imageops::resize(¤t, w, h, image::imageops::FilterType::Triangle);
|
||||
levels.push(RgbaImage {
|
||||
width: w,
|
||||
height: h,
|
||||
pixels: current.as_raw().clone(),
|
||||
});
|
||||
/// Mipmap-Stufen eines Bildes, jeweils halbe Kantenlänge bis 1 × 1. Für eine gerenderte
|
||||
/// PDF-Seite dauert das spürbar (2560 px: einige 10 ms); deshalb erzeugt sie der
|
||||
/// PDF-Render-Thread und nicht der Haupt-Thread.
|
||||
pub fn mip_chain(base: RgbaImage) -> Vec<RgbaImage> {
|
||||
let mut levels = vec![base];
|
||||
loop {
|
||||
let current = &levels[levels.len() - 1];
|
||||
if current.width <= 1 && current.height <= 1 {
|
||||
break;
|
||||
}
|
||||
let next = half_size(current);
|
||||
levels.push(next);
|
||||
}
|
||||
levels
|
||||
}
|
||||
|
||||
/// Halbiert ein Bild: Mittelwert aus je 2 × 2 Pixeln (bei ungerader Kante entfällt die
|
||||
/// letzte Spalte bzw. Zeile, bei Kantenlänge 1 wird nur in der anderen Richtung gemittelt).
|
||||
fn half_size(image: &RgbaImage) -> RgbaImage {
|
||||
let (w, h) = (image.width as usize, image.height as usize);
|
||||
let (nw, nh) = ((w / 2).max(1), (h / 2).max(1));
|
||||
let mut pixels = vec![0u8; nw * nh * 4];
|
||||
if image.pixels.len() < w * h * 4 {
|
||||
return RgbaImage {
|
||||
width: nw as u32,
|
||||
height: nh as u32,
|
||||
pixels,
|
||||
};
|
||||
}
|
||||
for y in 0..nh {
|
||||
let (y0, y1) = ((2 * y).min(h - 1), (2 * y + 1).min(h - 1));
|
||||
let (row0, row1) = (&image.pixels[y0 * w * 4..], &image.pixels[y1 * w * 4..]);
|
||||
let out = &mut pixels[y * nw * 4..(y + 1) * nw * 4];
|
||||
for x in 0..nw {
|
||||
let (x0, x1) = ((2 * x).min(w - 1) * 4, (2 * x + 1).min(w - 1) * 4);
|
||||
for c in 0..4 {
|
||||
let sum = row0[x0 + c] as u16
|
||||
+ row0[x1 + c] as u16
|
||||
+ row1[x0 + c] as u16
|
||||
+ row1[x1 + c] as u16;
|
||||
out[x * 4 + c] = ((sum + 2) / 4) as u8;
|
||||
}
|
||||
}
|
||||
}
|
||||
RgbaImage {
|
||||
width: nw as u32,
|
||||
height: nh as u32,
|
||||
pixels,
|
||||
}
|
||||
}
|
||||
|
||||
/// Name der Ersatztextur für Bilder, deren Datei fehlt oder beschädigt ist.
|
||||
const MISSING: &str = "";
|
||||
/// Bis zu dieser Grafikspeichermenge bleiben Texturen anderer Seiten erhalten (schnelles
|
||||
@@ -74,6 +105,8 @@ const TEXTURE_CACHE_BYTES: u64 = 384 * 1024 * 1024;
|
||||
/// Antwort einer Texturquelle für einen Schlüssel.
|
||||
pub enum TextureSource {
|
||||
Ready(RgbaImage),
|
||||
/// Fertige Mipmap-Stufen (siehe [`mip_chain`]), z. B. vom PDF-Render-Thread
|
||||
ReadyMips(Vec<RgbaImage>),
|
||||
/// Wird gerade erzeugt; später erneut fragen
|
||||
Pending,
|
||||
Failed(String),
|
||||
@@ -233,10 +266,16 @@ impl ImageLayer {
|
||||
continue;
|
||||
}
|
||||
let (bind_group, bytes) = match load(&key) {
|
||||
TextureSource::Ready(image) => (
|
||||
self.upload_texture(device, queue, &key, &image),
|
||||
image.pixels.len() as u64 * 4 / 3,
|
||||
),
|
||||
TextureSource::Ready(image) => {
|
||||
self.upload_texture(device, queue, &key, mip_chain(image))
|
||||
}
|
||||
TextureSource::ReadyMips(levels) if !levels.is_empty() => {
|
||||
self.upload_texture(device, queue, &key, levels)
|
||||
}
|
||||
TextureSource::ReadyMips(_) => {
|
||||
eprintln!("[Bilder] '{key}' kann nicht angezeigt werden: leeres Bild");
|
||||
(self.missing_texture(device, queue), 4)
|
||||
}
|
||||
// Noch in Arbeit (PDF-Seite wird gerendert): beim nächsten Bild erneut fragen
|
||||
TextureSource::Pending => continue,
|
||||
TextureSource::Failed(err) => {
|
||||
@@ -342,17 +381,19 @@ impl ImageLayer {
|
||||
height: 1,
|
||||
pixels: vec![200, 200, 200, 255],
|
||||
};
|
||||
self.upload_texture(device, queue, MISSING, &gray)
|
||||
self.upload_texture(device, queue, MISSING, vec![gray]).0
|
||||
}
|
||||
|
||||
/// Lädt die Mipmap-Stufen hoch (Stufe 0 = volle Größe); liefert die Bind Group und den
|
||||
/// belegten Grafikspeicher.
|
||||
fn upload_texture(
|
||||
&self,
|
||||
device: &wgpu::Device,
|
||||
queue: &wgpu::Queue,
|
||||
label: &str,
|
||||
image: &RgbaImage,
|
||||
) -> wgpu::BindGroup {
|
||||
let levels = mip_chain(image);
|
||||
levels: Vec<RgbaImage>,
|
||||
) -> (wgpu::BindGroup, u64) {
|
||||
let image = &levels[0];
|
||||
let texture = device.create_texture(&wgpu::TextureDescriptor {
|
||||
label: Some(label),
|
||||
size: wgpu::Extent3d {
|
||||
@@ -389,8 +430,9 @@ impl ImageLayer {
|
||||
},
|
||||
);
|
||||
}
|
||||
let bytes = levels.iter().map(|l| l.pixels.len() as u64).sum();
|
||||
let view = texture.create_view(&wgpu::TextureViewDescriptor::default());
|
||||
device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||
let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||
label: Some("Image Texture Bind Group"),
|
||||
layout: &self.texture_layout,
|
||||
entries: &[
|
||||
@@ -403,7 +445,8 @@ impl ImageLayer {
|
||||
resource: wgpu::BindingResource::Sampler(&self.sampler),
|
||||
},
|
||||
],
|
||||
})
|
||||
});
|
||||
(bind_group, bytes)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -439,11 +482,35 @@ mod tests {
|
||||
height: 3,
|
||||
pixels: vec![128; 8 * 3 * 4],
|
||||
};
|
||||
let levels = mip_chain(&base);
|
||||
let levels = mip_chain(base);
|
||||
let sizes: Vec<(u32, u32)> = levels.iter().map(|l| (l.width, l.height)).collect();
|
||||
assert_eq!(sizes, vec![(8, 3), (4, 1), (2, 1), (1, 1)]);
|
||||
for l in &levels {
|
||||
assert_eq!(l.pixels.len(), (l.width * l.height * 4) as usize);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_half_size_averages_two_by_two_blocks() {
|
||||
// 3 × 2: links schwarz/weiß übereinander, Mitte grau; die dritte Spalte entfällt
|
||||
let px = |v: u8| [v, v, v, 255];
|
||||
let pixels = [px(0), px(100), px(7), px(255), px(100), px(7)].concat();
|
||||
let base = RgbaImage {
|
||||
width: 3,
|
||||
height: 2,
|
||||
pixels,
|
||||
};
|
||||
let half = half_size(&base);
|
||||
assert_eq!((half.width, half.height), (1, 1));
|
||||
// (0 + 100 + 255 + 100) / 4 = 113,75 → 114
|
||||
assert_eq!(half.pixels, vec![114, 114, 114, 255]);
|
||||
|
||||
// Kantenlänge 1: nur in der anderen Richtung mitteln
|
||||
let column = RgbaImage {
|
||||
width: 1,
|
||||
height: 2,
|
||||
pixels: [px(10), px(20)].concat(),
|
||||
};
|
||||
assert_eq!(half_size(&column).pixels, vec![15, 15, 15, 255]);
|
||||
}
|
||||
}
|
||||
|
||||
+18
-18
@@ -941,25 +941,21 @@ fn texture_for(
|
||||
assets: &AssetStore,
|
||||
pdf_renderer: &mut PdfRenderService,
|
||||
) -> TextureSource {
|
||||
let result = match key.contains('#') {
|
||||
true => {
|
||||
let Some((Ok(path), page, max_pixels)) = ink_notes::pdf_doc::parse_page_key(key)
|
||||
.map(|(asset, page, px)| (assets.path_of(asset), page, px))
|
||||
else {
|
||||
return TextureSource::Failed(t!("Ungültiger PDF-Verweis '{key}'", key = key));
|
||||
};
|
||||
match pdf_renderer.take(key, path, page, max_pixels) {
|
||||
Some(result) => result,
|
||||
None => return TextureSource::Pending,
|
||||
}
|
||||
}
|
||||
false => assets
|
||||
.load_rgba(key, ink_notes::assets::MAX_DISPLAY_DIMENSION)
|
||||
.map_err(|e| e.to_string()),
|
||||
if !key.contains('#') {
|
||||
return match assets.load_rgba(key, ink_notes::assets::MAX_DISPLAY_DIMENSION) {
|
||||
Ok(image) => TextureSource::Ready(image),
|
||||
Err(err) => TextureSource::Failed(err.to_string()),
|
||||
};
|
||||
}
|
||||
let Some((Ok(path), page, max_pixels)) = ink_notes::pdf_doc::parse_page_key(key)
|
||||
.map(|(asset, page, px)| (assets.path_of(asset), page, px))
|
||||
else {
|
||||
return TextureSource::Failed(t!("Ungültiger PDF-Verweis '{key}'", key = key));
|
||||
};
|
||||
match result {
|
||||
Ok(image) => TextureSource::Ready(image),
|
||||
Err(err) => TextureSource::Failed(err),
|
||||
match pdf_renderer.take(key, path, page, max_pixels) {
|
||||
Some(Ok(levels)) => TextureSource::ReadyMips(levels),
|
||||
Some(Err(err)) => TextureSource::Failed(err),
|
||||
None => TextureSource::Pending,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3924,6 +3920,9 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
canvas.set_neighbor_pages(&[]);
|
||||
}
|
||||
let assets = AssetStore::new(&storage_dir);
|
||||
// PDF-Seiten, die nach diesem Bild nicht mehr gebraucht werden (schnelles
|
||||
// Scrollen), verwirft end_frame, bevor sie gerendert werden
|
||||
pdf_renderer.begin_frame();
|
||||
canvas.prepare_images(&mut |key| texture_for(key, &assets, &mut pdf_renderer));
|
||||
// Nächste PDF-Seite vorab rendern, damit Blättern sofort klappt
|
||||
if !canvas.is_waiting_for_images() {
|
||||
@@ -3934,6 +3933,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
}
|
||||
}
|
||||
}
|
||||
pdf_renderer.end_frame();
|
||||
match canvas.render(&clipped_primitives, &screen_descriptor, &full_output.textures_delta) {
|
||||
Ok(_) => {}
|
||||
Err(wgpu::SurfaceError::Lost | wgpu::SurfaceError::Outdated) => {
|
||||
|
||||
+239
-29
@@ -1,11 +1,13 @@
|
||||
//! Lesen und Rendern importierter PDF-Dateien mit `hayro` (reines Rust, nur CPU).
|
||||
//!
|
||||
//! Das Rendern einer Seite kann einige hundert Millisekunden dauern; [`PdfRenderService`]
|
||||
//! erledigt es deshalb in einem Hintergrund-Thread und meldet fertige Seiten zurück.
|
||||
//! erledigt es deshalb in einem Hintergrund-Thread, samt Mipmaps für die Anzeige, und meldet
|
||||
//! fertige Seiten zurück.
|
||||
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::path::PathBuf;
|
||||
use std::sync::mpsc::{self, Receiver, Sender};
|
||||
use std::sync::mpsc::{self, Receiver};
|
||||
use std::sync::{Arc, Condvar, Mutex};
|
||||
|
||||
use hayro::hayro_interpret::InterpreterSettings;
|
||||
use hayro::hayro_syntax::{LoadPdfError, Pdf};
|
||||
@@ -104,30 +106,107 @@ struct RenderRequest {
|
||||
path: PathBuf,
|
||||
page: usize,
|
||||
max_pixels: u32,
|
||||
/// Wichtigkeit: kleiner zuerst (Reihenfolge der Anfragen im aktuellen Bild)
|
||||
rank: usize,
|
||||
}
|
||||
|
||||
/// Ergebnis eines Render-Auftrags.
|
||||
pub type RenderResult = Result<RgbaImage, String>;
|
||||
/// Wichtigkeit einer vorab gerenderten Seite: nach allen sichtbaren.
|
||||
const PREFETCH_RANK: usize = usize::MAX;
|
||||
|
||||
/// Warteschlange der noch nicht begonnenen Render-Aufträge, geteilt mit dem Render-Thread.
|
||||
#[derive(Default)]
|
||||
struct RenderQueue {
|
||||
requests: Vec<RenderRequest>,
|
||||
shutdown: bool,
|
||||
}
|
||||
|
||||
impl RenderQueue {
|
||||
/// Fügt einen Auftrag hinzu oder macht einen wartenden wichtiger.
|
||||
fn push(&mut self, request: RenderRequest) {
|
||||
match self.requests.iter_mut().find(|r| r.key == request.key) {
|
||||
Some(queued) => queued.rank = queued.rank.min(request.rank),
|
||||
None => self.requests.push(request),
|
||||
}
|
||||
}
|
||||
|
||||
/// Nimmt den wichtigsten Auftrag heraus.
|
||||
fn pop(&mut self) -> Option<RenderRequest> {
|
||||
let (index, _) = self
|
||||
.requests
|
||||
.iter()
|
||||
.enumerate()
|
||||
.min_by_key(|(_, r)| r.rank)?;
|
||||
Some(self.requests.swap_remove(index))
|
||||
}
|
||||
|
||||
/// Verwirft Aufträge, für die `rank_of` keinen Rang mehr liefert, und ordnet die übrigen
|
||||
/// neu. Liefert die Schlüssel der verworfenen.
|
||||
fn retain(&mut self, rank_of: impl Fn(&str) -> Option<usize>) -> Vec<String> {
|
||||
let mut dropped = Vec::new();
|
||||
self.requests.retain_mut(|r| match rank_of(&r.key) {
|
||||
Some(rank) => {
|
||||
r.rank = rank;
|
||||
true
|
||||
}
|
||||
None => {
|
||||
dropped.push(r.key.clone());
|
||||
false
|
||||
}
|
||||
});
|
||||
dropped
|
||||
}
|
||||
}
|
||||
|
||||
type SharedQueue = Arc<(Mutex<RenderQueue>, Condvar)>;
|
||||
|
||||
/// Ergebnis eines Render-Auftrags: die Mipmap-Stufen der Seite (siehe
|
||||
/// [`crate::image_layer::mip_chain`]), Stufe 0 in voller Größe.
|
||||
pub type RenderResult = Result<Vec<RgbaImage>, String>;
|
||||
|
||||
/// Rendert PDF-Seiten im Hintergrund. Fertige Seiten holt der Event-Loop mit
|
||||
/// [`PdfRenderService::take`] ab; `on_ready` weckt ihn dafür auf.
|
||||
///
|
||||
/// Je Bild meldet der Event-Loop zwischen [`Self::begin_frame`] und [`Self::end_frame`] mit
|
||||
/// `take` alle Seiten an, die er gerade braucht (die wichtigste zuerst). Gerendert wird die
|
||||
/// wichtigste wartende Seite zuerst; Seiten, die beim schnellen Scrollen schon wieder aus dem
|
||||
/// Bild sind, verwirft `end_frame`, bevor der Render-Thread sie beginnt.
|
||||
pub struct PdfRenderService {
|
||||
requests: Sender<RenderRequest>,
|
||||
queue: SharedQueue,
|
||||
worker: std::thread::JoinHandle<()>,
|
||||
results: Receiver<(String, RenderResult)>,
|
||||
/// Wartende oder gerade gerenderte Seiten
|
||||
pending: HashSet<String>,
|
||||
ready: HashMap<String, RenderResult>,
|
||||
/// Im aktuellen Bild angeforderte Seiten in ihrer Reihenfolge
|
||||
wanted: Vec<String>,
|
||||
/// Zuletzt vorab angeforderte Seite (bleibt erhalten, bis sie abgeholt wird)
|
||||
prefetched: Option<String>,
|
||||
}
|
||||
|
||||
impl PdfRenderService {
|
||||
pub fn new(on_ready: impl Fn() + Send + 'static) -> Self {
|
||||
let (req_tx, req_rx) = mpsc::channel::<RenderRequest>();
|
||||
let queue: SharedQueue = Arc::default();
|
||||
let (res_tx, res_rx) = mpsc::channel();
|
||||
std::thread::Builder::new()
|
||||
let worker_queue = Arc::clone(&queue);
|
||||
let worker = std::thread::Builder::new()
|
||||
.name("pdf-render".into())
|
||||
.spawn(move || {
|
||||
// Zuletzt geöffnetes PDF behalten: Blättern rendert meist dieselbe Datei
|
||||
let mut open_pdf: Option<(PathBuf, Pdf)> = None;
|
||||
while let Ok(request) = req_rx.recv() {
|
||||
loop {
|
||||
let request = {
|
||||
let (lock, ready) = &*worker_queue;
|
||||
let mut queue = lock.lock().unwrap_or_else(|e| e.into_inner());
|
||||
loop {
|
||||
if queue.shutdown {
|
||||
return;
|
||||
}
|
||||
if let Some(request) = queue.pop() {
|
||||
break request;
|
||||
}
|
||||
queue = ready.wait(queue).unwrap_or_else(|e| e.into_inner());
|
||||
}
|
||||
};
|
||||
if open_pdf.as_ref().map(|(p, _)| p) != Some(&request.path) {
|
||||
open_pdf = std::fs::read(&request.path)
|
||||
.map_err(|e| e.to_string())
|
||||
@@ -137,36 +216,64 @@ impl PdfRenderService {
|
||||
.map(|pdf| (request.path.clone(), pdf));
|
||||
}
|
||||
let result = match &open_pdf {
|
||||
Some((_, pdf)) => render_page(pdf, request.page, request.max_pixels),
|
||||
// Mipmaps hier statt im Haupt-Thread: sonst stockt das Scrollen bei
|
||||
// jeder neu gerenderten Seite (Issue #28)
|
||||
Some((_, pdf)) => render_page(pdf, request.page, request.max_pixels)
|
||||
.map(crate::image_layer::mip_chain),
|
||||
None => Err(t!("PDF kann nicht geöffnet werden").into()),
|
||||
};
|
||||
if res_tx.send((request.key, result)).is_err() {
|
||||
break;
|
||||
return;
|
||||
}
|
||||
on_ready();
|
||||
}
|
||||
})
|
||||
.expect("PDF-Render-Thread konnte nicht gestartet werden");
|
||||
Self {
|
||||
requests: req_tx,
|
||||
queue,
|
||||
worker,
|
||||
results: res_rx,
|
||||
pending: HashSet::new(),
|
||||
ready: HashMap::new(),
|
||||
wanted: Vec::new(),
|
||||
prefetched: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Beginnt die Anfragen eines neuen Bildes (siehe [`Self::end_frame`]).
|
||||
pub fn begin_frame(&mut self) {
|
||||
self.wanted.clear();
|
||||
}
|
||||
|
||||
/// Verwirft wartende Aufträge und fertige Ergebnisse für Seiten, die in diesem Bild nicht
|
||||
/// mehr angefordert wurden (außer der vorab angeforderten), und ordnet die übrigen nach
|
||||
/// der Reihenfolge der Anfragen.
|
||||
pub fn end_frame(&mut self) {
|
||||
let rank_of = |key: &str| {
|
||||
self.wanted
|
||||
.iter()
|
||||
.position(|k| k == key)
|
||||
.or_else(|| (self.prefetched.as_deref() == Some(key)).then_some(PREFETCH_RANK))
|
||||
};
|
||||
let dropped = {
|
||||
let mut queue = self.queue.0.lock().unwrap_or_else(|e| e.into_inner());
|
||||
queue.retain(rank_of)
|
||||
};
|
||||
for key in dropped {
|
||||
self.pending.remove(&key);
|
||||
}
|
||||
self.ready.retain(|key, _| rank_of(key).is_some());
|
||||
}
|
||||
|
||||
/// Rendert eine Seite vorab (z. B. die nächste), ohne auf das Ergebnis zu warten;
|
||||
/// [`Self::take`] liefert es später sofort.
|
||||
pub fn prefetch(&mut self, key: &str, path: PathBuf, page: usize) {
|
||||
self.collect();
|
||||
if self.ready.contains_key(key) || self.pending.contains(key) {
|
||||
self.prefetched = Some(key.to_string());
|
||||
if self.ready.contains_key(key) {
|
||||
return;
|
||||
}
|
||||
// Vorab gerenderte Seiten nicht unbegrenzt aufheben
|
||||
if self.ready.len() >= 2 {
|
||||
self.ready.clear();
|
||||
}
|
||||
self.request(key, path, page, DISPLAY_RENDER_PIXELS);
|
||||
self.request(key, path, page, DISPLAY_RENDER_PIXELS, PREFETCH_RANK);
|
||||
}
|
||||
|
||||
fn collect(&mut self) {
|
||||
@@ -176,21 +283,37 @@ impl PdfRenderService {
|
||||
}
|
||||
}
|
||||
|
||||
/// Erteilt den Auftrag, falls noch keiner läuft. `false`, wenn der Thread beendet ist.
|
||||
fn request(&mut self, key: &str, path: PathBuf, page: usize, max_pixels: u32) -> bool {
|
||||
if !self.pending.insert(key.to_string()) {
|
||||
/// Erteilt den Auftrag bzw. macht einen wartenden wichtiger. `false`, wenn der
|
||||
/// Render-Thread beendet ist.
|
||||
fn request(
|
||||
&mut self,
|
||||
key: &str,
|
||||
path: PathBuf,
|
||||
page: usize,
|
||||
max_pixels: u32,
|
||||
rank: usize,
|
||||
) -> bool {
|
||||
if self.worker.is_finished() {
|
||||
self.pending.remove(key);
|
||||
return false;
|
||||
}
|
||||
let (lock, ready) = &*self.queue;
|
||||
let mut queue = lock.lock().unwrap_or_else(|e| e.into_inner());
|
||||
// Steht ein angefragter Auftrag nicht mehr in der Warteschlange, rendert ihn der
|
||||
// Thread gerade: nicht doppelt erteilen
|
||||
let waiting = queue.requests.iter().any(|r| r.key == key);
|
||||
if self.pending.contains(key) && !waiting {
|
||||
return true;
|
||||
}
|
||||
let request = RenderRequest {
|
||||
self.pending.insert(key.to_string());
|
||||
queue.push(RenderRequest {
|
||||
key: key.to_string(),
|
||||
path,
|
||||
page,
|
||||
max_pixels,
|
||||
};
|
||||
if self.requests.send(request).is_err() {
|
||||
self.pending.remove(key);
|
||||
return false;
|
||||
}
|
||||
rank,
|
||||
});
|
||||
ready.notify_one();
|
||||
true
|
||||
}
|
||||
|
||||
@@ -204,16 +327,29 @@ impl PdfRenderService {
|
||||
max_pixels: u32,
|
||||
) -> Option<RenderResult> {
|
||||
self.collect();
|
||||
if self.prefetched.as_deref() == Some(key) {
|
||||
self.prefetched = None;
|
||||
}
|
||||
if let Some(result) = self.ready.remove(key) {
|
||||
return Some(result);
|
||||
}
|
||||
if !self.request(key, path, page, max_pixels) {
|
||||
let rank = self.wanted.len();
|
||||
self.wanted.push(key.to_string());
|
||||
if !self.request(key, path, page, max_pixels, rank) {
|
||||
return Some(Err(t!("PDF-Render-Thread beendet").into()));
|
||||
}
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for PdfRenderService {
|
||||
fn drop(&mut self) {
|
||||
let (lock, ready) = &*self.queue;
|
||||
lock.lock().unwrap_or_else(|e| e.into_inner()).shutdown = true;
|
||||
ready.notify_all();
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) mod tests {
|
||||
use super::*;
|
||||
@@ -303,8 +439,12 @@ pub(crate) mod tests {
|
||||
wake_rx
|
||||
.recv_timeout(std::time::Duration::from_secs(20))
|
||||
.unwrap();
|
||||
let image = service.take("a#0", path.clone(), 0, px).unwrap().unwrap();
|
||||
assert_eq!(image.width, DISPLAY_RENDER_PIXELS);
|
||||
let levels = service.take("a#0", path.clone(), 0, px).unwrap().unwrap();
|
||||
assert_eq!(levels[0].width, DISPLAY_RENDER_PIXELS);
|
||||
assert_eq!(
|
||||
(levels.last().unwrap().width, levels.last().unwrap().height),
|
||||
(1, 1)
|
||||
);
|
||||
|
||||
// Vorab rendern: danach sofort verfügbar
|
||||
service.prefetch("a#0", path.clone(), 0);
|
||||
@@ -314,6 +454,76 @@ pub(crate) mod tests {
|
||||
assert!(service.take("a#0", path, 0, px).unwrap().is_ok());
|
||||
}
|
||||
|
||||
fn request(key: &str, rank: usize) -> RenderRequest {
|
||||
RenderRequest {
|
||||
key: key.into(),
|
||||
path: PathBuf::new(),
|
||||
page: 0,
|
||||
max_pixels: 1,
|
||||
rank,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_render_queue_priorities_and_stale_requests() {
|
||||
let mut queue = RenderQueue::default();
|
||||
queue.push(request("a", 2));
|
||||
queue.push(request("b", 0));
|
||||
queue.push(request("c", 1));
|
||||
// Erneute Anfrage macht einen wartenden Auftrag wichtiger, ohne ihn zu verdoppeln
|
||||
queue.push(request("a", 0));
|
||||
assert_eq!(queue.requests.len(), 3);
|
||||
assert_eq!(queue.pop().map(|r| r.key), Some("a".into()));
|
||||
|
||||
// Nach dem Scrollen wird nur noch „c“ gebraucht: „b“ fällt weg
|
||||
let dropped = queue.retain(|key| (key == "c").then_some(0));
|
||||
assert_eq!(dropped, vec!["b".to_string()]);
|
||||
assert_eq!(queue.pop().map(|r| r.key), Some("c".into()));
|
||||
assert!(queue.pop().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_end_frame_drops_unwanted_results_but_keeps_prefetch() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("a.pdf");
|
||||
std::fs::write(&path, sample_pdf(&[(400.0, 300.0, 0), (400.0, 300.0, 0)])).unwrap();
|
||||
let (wake_tx, wake_rx) = mpsc::channel();
|
||||
let mut service = PdfRenderService::new(move || {
|
||||
let _ = wake_tx.send(());
|
||||
});
|
||||
let px = DISPLAY_RENDER_PIXELS;
|
||||
let wait = || {
|
||||
wake_rx
|
||||
.recv_timeout(std::time::Duration::from_secs(20))
|
||||
.unwrap()
|
||||
};
|
||||
|
||||
// Bild 1 braucht Seite 1; vorab gerendert wird Seite 2
|
||||
service.begin_frame();
|
||||
assert!(service.take("a#0", path.clone(), 0, px).is_none());
|
||||
service.prefetch("a#1", path.clone(), 1);
|
||||
service.end_frame();
|
||||
wait();
|
||||
wait();
|
||||
|
||||
// Bild 2 braucht keine Seite mehr (weggescrollt): Seite 1 wird verworfen,
|
||||
// die vorab gerenderte Seite 2 bleibt
|
||||
service.begin_frame();
|
||||
service.collect();
|
||||
service.end_frame();
|
||||
assert!(!service.ready.contains_key("a#0"));
|
||||
assert!(service.ready.contains_key("a#1"));
|
||||
assert!(service.pending.is_empty());
|
||||
|
||||
service.begin_frame();
|
||||
assert!(service.take("a#1", path.clone(), 1, px).unwrap().is_ok());
|
||||
// Seite 1 wird bei Bedarf neu gerendert
|
||||
assert!(service.take("a#0", path.clone(), 0, px).is_none());
|
||||
service.end_frame();
|
||||
wait();
|
||||
assert!(service.take("a#0", path, 0, px).unwrap().is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_page_key() {
|
||||
assert_eq!(
|
||||
|
||||
Reference in New Issue
Block a user