From 2bf134dc7286d4ba79c3e6042ca7b560cba67762 Mon Sep 17 00:00:00 2001 From: Mani <12841290+krmanik@users.noreply.github.com> Date: Wed, 29 Mar 2023 10:33:19 +0800 Subject: [PATCH] Feature image occlusion (#2367) * add note types with occlusions and image fields * generate image occlusion cloze div data - generate div element with data-* atrributes for canvas shape generate for reviewer * getting image data & deck id and adding notes the implementation added into backend - added service index in backend.proto for image occlusion request - created image_occlusion.proto with required message and service - implementation in backend for getting image and adding notes, also during editing return imagecloze note and update notes - add notes to selected deck, if no notetype then add image occlusion notetypes - reuse notetype from stock notetypes when not exist * script for generating shapes using canvas api in reviewer - the flash issues fixed by loading image and using image size to draw canvas, also when image get resized, calculate scale using natural width and canvas width to draw shape at right position - limit size of canvas for safari * init image occlusion page in ts and build page with - fabricjs for editing shapes - panzoom for drag and zoom - pickr for color picker - build page using web.rs * implement top toolbar for canvas shapes - undo & redo tools - zoom in, zoom out and zoom fit - group & ungroup - copy & paste - set transparency of shapes - align tools * implement side toolbar for drawing shapes add top toolbar and the side toolbar contains following tools - cursor for selecting shapes - zoom for drag and zoom shapes in mask editor - rectangle for creating it - ellipse for creating it - polygon for creating it using points - shape fill color - question mask color (currently only single color can be added for all shapes) * add maskeditor page for editing mask - add side toolbar and sidebar include toptoolbar - load maskeditor in two mode - for adding note using path to image - for editing note using note id * implement note editor page for adding notes - the note editor page have simple button (B/I/U) and option to toggle html view - option to select deck for adding notes into that deck - option to generate to hide all, guess one & hide one, guess one notes * add image occlusion page add side toolbar, top toolbar, mask editor and note editor - option to switch between mask editor and note editor * implement generates notes and save notes implemention to show toast components for messages * removed pickr & implemented color picker component - remove pickr - implemented using html5 canvas - range input for changing color - another range input for opacity changes - hex and rgba value support * rename methods name & rust unwrap safety - change plural names to singular - create respone message in proto and return response with imagecloze note or error if not found with note id - remove image_occlusion from post handler list - rename service name in mediasrv.py - rename methods name for image occlusion in backend and image_occlusion - update frontend also for update functions' names - handle error in frontend mask-editor.ts, when error getting notes then toast message shown to frontend * extract to function & add comments & remove global - extract function in mask-editor.ts to reduce duplicate - remove unused global from css - add comments to store.ts explaining usage - changes id to noteId in lib.ts - add comments for limitSize, becuase of duplicate implementation * remove image_occlusion notetype - remove from stock notetype, stdmodels - add implementation for notetype to image occlusion - add i18n for errors * update smooth scroll, always show cursor tools - change questionmask to qmask - make selectable for shape true in all tools to simplify edits and draw shapes - update image occlusion in reviewer ts to load image properly * add and get notetype else return errors * fix: not showing occlusion * Use a oneof for ImageClozeNoteResponse Makes it clearer that only one of them can be returned * Don't crash if image filename not provided The second unwrap should be ok, as the input is utf8 * Refactor get_image_cloze_note - fixes crash when note doesn't exist - Ok(None) case was not covered - decouples business logic from native error->proto error conversion - no need for original copy - field[x] is more idiomatic than field.get(x).unwrap() - don't need mutable access to fields * Fix crash if image file unreadable + Use our read_file helper for better error context * Add metadata() helper * Fix crash if file metadata can't be read * remove color picker, qmask and shape color - remove strings from ftl - remove color picker component - remove from cloze generation - remove icons for two buttons - use constant color for shapes * update color in reviewer and ftl strings * fix shape position in canvas & add border to shape - rename mask to inactive shape and active shape color - border witdth and border color - change decimal point deserializing string and toFixed(2) - add thin border in mask editor, may be image background was transparent * fix shape position in canvas after modified - do not draw fixed ratio shapes by turn of uniformScaling - fix rectangle width,height - fix ellipse rx,ry,width,height - fix polygon postion and points - draw outside of canvas also * fix border width and color in reviewer canvas - rename variable * refactor cloze div generate and remove angle * fix origin when drawn outside of canvas from right * fix shape at boundry & not include rx,ry rectangle - move shapes at boundry when pointer is outside of canvas - include rx, ry for ellipse only - include points for polygon only * fix lint errors & update image size in editor canvas based on height and width * remove unsupported layerX & layerX for touchscreen - fix shapes at edges * implemented undo redo with canvas state - implemented undo redo using fabric canvas events - polygon is special case and implemented only added and modified event - rectangle and ellipse have object:added, object:modified and object:removed case - change id to undo and redo * remove background image from canvas and used css to put image tag below canvas editor - set image width and height after adding image * fix for polygon points, add br in cloze strings, & toogle masks button - fix shapes at edges - toggle masks button to show/hide masks - hide clozes string, it contains
- set height for div container (used 'relative' in css) * refactor top toolbar, add space and border radius - rename cursor tools - add left and right border * fix undo after undo happen, use transparent color in draw mode --- build/configure/src/web.rs | 12 + ftl/core/importing.ftl | 5 + ftl/core/notetypes.ftl | 15 + package.json | 2 + proto/anki/backend.proto | 1 + proto/anki/image_occlusion.proto | 67 ++ proto/anki/notetypes.proto | 1 + pylib/anki/collection.py | 44 + pylib/tools/genbackend.py | 3 + qt/aqt/mediasrv.py | 5 + rslib/src/backend/image_occlusion.rs | 55 + rslib/src/backend/mod.rs | 5 + rslib/src/cloze.rs | 46 + rslib/src/error/file_io.rs | 2 + .../image_occlusion_styling.css | 41 + rslib/src/image_occlusion/imagedata.rs | 246 +++++ rslib/src/image_occlusion/imageocclusion.rs | 107 ++ rslib/src/image_occlusion/mod.rs | 5 + rslib/src/io.rs | 8 + rslib/src/lib.rs | 1 + rslib/src/notetype/mod.rs | 2 +- rslib/src/ops.rs | 2 + rslib/src/pb.rs | 1 + ts/image-occlusion/ImageOcclusionPage.svelte | 106 ++ ts/image-occlusion/MaskEditor.svelte | 81 ++ ts/image-occlusion/Notes.svelte | 93 ++ ts/image-occlusion/SideToolbar.svelte | 97 ++ ts/image-occlusion/StickyFooter.svelte | 67 ++ ts/image-occlusion/Tags.svelte | 28 + ts/image-occlusion/Toast.svelte | 61 ++ ts/image-occlusion/TopToolbar.svelte | 194 ++++ ts/image-occlusion/generate.ts | 166 +++ ts/image-occlusion/icons.ts | 33 + ts/image-occlusion/image-occlusion-base.scss | 19 + ts/image-occlusion/index.ts | 56 + ts/image-occlusion/lib.ts | 61 ++ ts/image-occlusion/mask-editor.ts | 143 +++ .../notes-toolbar/MoreTools.svelte | 29 + .../notes-toolbar/NotesToolbar.svelte | 13 + .../notes-toolbar/TextFormatting.svelte | 58 ++ ts/image-occlusion/notes-toolbar/index.ts | 6 + ts/image-occlusion/notes-toolbar/lib.ts | 25 + ts/image-occlusion/store.ts | 11 + ts/image-occlusion/tools/index.ts | 8 + ts/image-occlusion/tools/lib.ts | 208 ++++ ts/image-occlusion/tools/more-tools.ts | 104 ++ ts/image-occlusion/tools/shape-generate.ts | 131 +++ ts/image-occlusion/tools/tool-aligns.ts | 210 ++++ ts/image-occlusion/tools/tool-buttons.ts | 33 + ts/image-occlusion/tools/tool-ellipse.ts | 121 +++ ts/image-occlusion/tools/tool-polygon.ts | 233 +++++ ts/image-occlusion/tools/tool-rect.ts | 115 ++ ts/image-occlusion/tools/tool-undo-redo.ts | 90 ++ ts/image-occlusion/tsconfig.json | 17 + ts/lib/proto.ts | 4 + ts/licenses.json | 981 ++++++++++++++++++ ts/reviewer/image_occlusion.ts | 194 ++++ ts/reviewer/index.ts | 2 + ts/reviewer/reviewer_extras.ts | 2 + yarn.lock | 281 ++++- 60 files changed, 4750 insertions(+), 7 deletions(-) create mode 100644 proto/anki/image_occlusion.proto create mode 100644 rslib/src/backend/image_occlusion.rs create mode 100644 rslib/src/image_occlusion/image_occlusion_styling.css create mode 100644 rslib/src/image_occlusion/imagedata.rs create mode 100644 rslib/src/image_occlusion/imageocclusion.rs create mode 100644 rslib/src/image_occlusion/mod.rs create mode 100644 ts/image-occlusion/ImageOcclusionPage.svelte create mode 100644 ts/image-occlusion/MaskEditor.svelte create mode 100644 ts/image-occlusion/Notes.svelte create mode 100644 ts/image-occlusion/SideToolbar.svelte create mode 100644 ts/image-occlusion/StickyFooter.svelte create mode 100644 ts/image-occlusion/Tags.svelte create mode 100644 ts/image-occlusion/Toast.svelte create mode 100644 ts/image-occlusion/TopToolbar.svelte create mode 100644 ts/image-occlusion/generate.ts create mode 100644 ts/image-occlusion/icons.ts create mode 100644 ts/image-occlusion/image-occlusion-base.scss create mode 100644 ts/image-occlusion/index.ts create mode 100644 ts/image-occlusion/lib.ts create mode 100644 ts/image-occlusion/mask-editor.ts create mode 100644 ts/image-occlusion/notes-toolbar/MoreTools.svelte create mode 100644 ts/image-occlusion/notes-toolbar/NotesToolbar.svelte create mode 100644 ts/image-occlusion/notes-toolbar/TextFormatting.svelte create mode 100644 ts/image-occlusion/notes-toolbar/index.ts create mode 100644 ts/image-occlusion/notes-toolbar/lib.ts create mode 100644 ts/image-occlusion/store.ts create mode 100644 ts/image-occlusion/tools/index.ts create mode 100644 ts/image-occlusion/tools/lib.ts create mode 100644 ts/image-occlusion/tools/more-tools.ts create mode 100644 ts/image-occlusion/tools/shape-generate.ts create mode 100644 ts/image-occlusion/tools/tool-aligns.ts create mode 100644 ts/image-occlusion/tools/tool-buttons.ts create mode 100644 ts/image-occlusion/tools/tool-ellipse.ts create mode 100644 ts/image-occlusion/tools/tool-polygon.ts create mode 100644 ts/image-occlusion/tools/tool-rect.ts create mode 100644 ts/image-occlusion/tools/tool-undo-redo.ts create mode 100644 ts/image-occlusion/tsconfig.json create mode 100644 ts/reviewer/image_occlusion.ts diff --git a/build/configure/src/web.rs b/build/configure/src/web.rs index 95595cf03..2312eb541 100644 --- a/build/configure/src/web.rs +++ b/build/configure/src/web.rs @@ -334,6 +334,18 @@ fn build_and_check_pages(build: &mut Build) -> Result<()> { ":sass" ], )?; + build_page( + "image-occlusion", + true, + inputs![ + // + ":ts:lib", + ":ts:components", + ":ts:sveltelib", + ":ts:tag-editor", + ":sass" + ], + )?; Ok(()) } diff --git a/ftl/core/importing.ftl b/ftl/core/importing.ftl index 6c0ab352b..c0f158deb 100644 --- a/ftl/core/importing.ftl +++ b/ftl/core/importing.ftl @@ -108,6 +108,11 @@ importing-tag-updated-notes = Tag updated notes importing-file = File importing-match-scope = Match scope importing-notetype-and-deck = Notetype and deck +importing-cards-added = + { $count -> + [one] { $count } card added. + *[other] { $count } cards added. + } ## NO NEED TO TRANSLATE. This text is no longer used by Anki, and will be removed in the future. diff --git a/ftl/core/notetypes.ftl b/ftl/core/notetypes.ftl index 0b173fe45..353ea308d 100644 --- a/ftl/core/notetypes.ftl +++ b/ftl/core/notetypes.ftl @@ -35,3 +35,18 @@ notetypes-note-types = Note Types notetypes-options = Options notetypes-please-add-another-note-type-first = Please add another note type first. notetypes-type = Type + +## Image Occlusion + +notetypes-image = Image +notetypes-occlusion = Occlusion +notetypes-occlusion-mask = Mask +notetypes-occlusion-note = Note +notetypes-comments-field = Comments +notetypes-toggle-masks = Toggle Masks +notetypes-image-occlusion-name = Image Occlusion +notetypes-hide-all-guess-one = Hide All, Guess One +notetypes-hide-one-guess-one = Hide One, Guess One +notetypes-error-generating-cloze = An error occurred when generating an image occlusion note +notetypes-error-getting-imagecloze = An error occurred while fetching an image occlusion note +notetypes-error-loading-image-occlusion = Error loading image occlusion. Is your Anki version up to date? diff --git a/package.json b/package.json index 7833d1468..b9298e35f 100644 --- a/package.json +++ b/package.json @@ -74,6 +74,7 @@ "codemirror": "^5.63.1", "css-browser-selector": "^0.6.5", "d3": "^7.0.0", + "fabric": "^5.3.0", "fuse.js": "^6.6.2", "gemoji": "^7.1.0", "intl-pluralrules": "^1.2.2", @@ -82,6 +83,7 @@ "lodash-es": "^4.17.21", "marked": "^4.0.0", "mathjax": "^3.1.2", + "panzoom": "^9.4.3", "protobufjs": "^7" }, "resolutions": { diff --git a/proto/anki/backend.proto b/proto/anki/backend.proto index 89c5f1f1e..8db0550a8 100644 --- a/proto/anki/backend.proto +++ b/proto/anki/backend.proto @@ -31,6 +31,7 @@ enum ServiceIndex { SERVICE_INDEX_LINKS = 15; SERVICE_INDEX_IMPORT_EXPORT = 16; SERVICE_INDEX_ANKIDROID = 17; + SERVICE_INDEX_IMAGE_OCCLUSION = 18; } message BackendInit { diff --git a/proto/anki/image_occlusion.proto b/proto/anki/image_occlusion.proto new file mode 100644 index 000000000..07c8f0571 --- /dev/null +++ b/proto/anki/image_occlusion.proto @@ -0,0 +1,67 @@ +// Copyright: Ankitects Pty Ltd and contributors +// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html + +syntax = "proto3"; + +option java_multiple_files = true; + +package anki.image_occlusion; + +import "anki/cards.proto"; +import "anki/collection.proto"; +import "anki/notes.proto"; +import "anki/generic.proto"; + +service ImageOcclusionService { + rpc GetImageForOcclusion(GetImageForOcclusionRequest) returns (ImageData); + rpc AddImageOcclusionNote(AddImageOcclusionNoteRequest) + returns (collection.OpChanges); + rpc GetImageClozeNote(GetImageOcclusionNoteRequest) + returns (ImageClozeNoteResponse); + rpc UpdateImageOcclusionNote(UpdateImageOcclusionNoteRequest) + returns (collection.OpChanges); +} + +message GetImageForOcclusionRequest { + string path = 1; +} + +message ImageData { + bytes data = 1; + string name = 2; +} + +message AddImageOcclusionNoteRequest { + string image_path = 1; + string occlusions = 2; + string header = 3; + string back_extra = 4; + repeated string tags = 5; +} + +message ImageClozeNote { + bytes image_data = 1; + string occlusions = 2; + string header = 3; + string back_extra = 4; + repeated string tags = 5; +} + +message GetImageOcclusionNoteRequest { + int64 note_id = 1; +} + +message UpdateImageOcclusionNoteRequest { + int64 note_id = 1; + string occlusions = 2; + string header = 3; + string back_extra = 4; + repeated string tags = 5; +} + +message ImageClozeNoteResponse { + oneof value { + ImageClozeNote note = 1; + string error = 2; + } +} diff --git a/proto/anki/notetypes.proto b/proto/anki/notetypes.proto index 1c837cd02..33259c3e1 100644 --- a/proto/anki/notetypes.proto +++ b/proto/anki/notetypes.proto @@ -124,6 +124,7 @@ message StockNotetype { BASIC_OPTIONAL_REVERSED = 2; BASIC_TYPING = 3; CLOZE = 4; + IMAGE_OCCLUSION = 5; } Kind kind = 1; diff --git a/pylib/anki/collection.py b/pylib/anki/collection.py index f3b58ae4b..748ae9824 100644 --- a/pylib/anki/collection.py +++ b/pylib/anki/collection.py @@ -10,6 +10,7 @@ from anki import ( collection_pb2, config_pb2, generic_pb2, + image_occlusion_pb2, import_export_pb2, links_pb2, search_pb2, @@ -40,6 +41,9 @@ CsvMetadata = import_export_pb2.CsvMetadata DupeResolution = CsvMetadata.DupeResolution Delimiter = import_export_pb2.CsvMetadata.Delimiter TtsVoice = card_rendering_pb2.AllTtsVoicesResponse.TtsVoice +ImageData = image_occlusion_pb2.ImageData +AddImageOcclusionNoteRequest = image_occlusion_pb2.AddImageOcclusionNoteRequest +ImageClozeNoteResponse = image_occlusion_pb2.ImageClozeNoteResponse import copy import os @@ -456,6 +460,46 @@ class Collection(DeprecatedNamesMixin): def import_json_string(self, json: str) -> ImportLogWithChanges: return self._backend.import_json_string(json) + # Image Occlusion + ########################################################################## + def get_image_for_occlusion(self, path: str | None) -> ImageData: + return self._backend.get_image_for_occlusion(path=path) + + def add_image_occlusion_note( + self, + image_path: str | None, + occlusions: str | None, + header: str | None, + back_extra: str | None, + tags: list[str] | None, + ) -> OpChanges: + return self._backend.add_image_occlusion_note( + image_path=image_path, + occlusions=occlusions, + header=header, + back_extra=back_extra, + tags=tags, + ) + + def get_image_cloze_note(self, note_id: int | None) -> ImageClozeNoteResponse: + return self._backend.get_image_cloze_note(note_id=note_id) + + def update_image_occlusion_note( + self, + note_id: int | None, + occlusions: str | None, + header: str | None, + back_extra: str | None, + tags: list[str] | None, + ) -> OpChanges: + return self._backend.update_image_occlusion_note( + note_id=note_id, + occlusions=occlusions, + header=header, + back_extra=back_extra, + tags=tags, + ) + # Object helpers ########################################################################## diff --git a/pylib/tools/genbackend.py b/pylib/tools/genbackend.py index 36856ccbb..2b2d3d0ae 100644 --- a/pylib/tools/genbackend.py +++ b/pylib/tools/genbackend.py @@ -19,6 +19,7 @@ import anki.config_pb2 import anki.deckconfig_pb2 import anki.decks_pb2 import anki.i18n_pb2 +import anki.image_occlusion_pb2 import anki.import_export_pb2 import anki.links_pb2 import anki.media_pb2 @@ -187,6 +188,7 @@ service_modules = dict( MEDIA=anki.media_pb2, LINKS=anki.links_pb2, IMPORT_EXPORT=anki.import_export_pb2, + IMAGE_OCCLUSION=anki.image_occlusion_pb2, ) for service in anki.backend_pb2.ServiceIndex.DESCRIPTOR.values: @@ -238,6 +240,7 @@ import anki.card_rendering_pb2 import anki.tags_pb2 import anki.media_pb2 import anki.import_export_pb2 +import anki.image_occlusion_pb2 class RustBackendGenerated: def _run_command(self, service: int, method: int, input: Any) -> bytes: diff --git a/qt/aqt/mediasrv.py b/qt/aqt/mediasrv.py index b0a6298e2..6e2e2086d 100644 --- a/qt/aqt/mediasrv.py +++ b/qt/aqt/mediasrv.py @@ -479,6 +479,11 @@ exposed_backend_list = [ "set_graph_preferences", # TagsService "complete_tag", + # ImageOcclusionService + "get_image_for_occlusion", + "add_image_occlusion_note", + "get_image_cloze_note", + "update_image_occlusion_note", ] diff --git a/rslib/src/backend/image_occlusion.rs b/rslib/src/backend/image_occlusion.rs new file mode 100644 index 000000000..fe549e21e --- /dev/null +++ b/rslib/src/backend/image_occlusion.rs @@ -0,0 +1,55 @@ +// Copyright: Ankitects Pty Ltd and contributors +// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html + +use super::Backend; +pub(super) use crate::pb::image_occlusion::imageocclusion_service::Service as ImageOcclusionService; +use crate::pb::{self as pb,}; +use crate::prelude::*; + +impl ImageOcclusionService for Backend { + fn get_image_for_occlusion( + &self, + input: pb::image_occlusion::GetImageForOcclusionRequest, + ) -> Result { + self.with_col(|col| col.get_image_for_occlusion(&input.path)) + } + + fn add_image_occlusion_note( + &self, + input: pb::image_occlusion::AddImageOcclusionNoteRequest, + ) -> Result { + self.with_col(|col| { + col.add_image_occlusion_note( + &input.image_path, + &input.occlusions, + &input.header, + &input.back_extra, + input.tags, + ) + }) + .map(Into::into) + } + + fn get_image_cloze_note( + &self, + input: pb::image_occlusion::GetImageOcclusionNoteRequest, + ) -> Result { + self.with_col(|col| col.get_image_cloze_note(input.note_id.into())) + } + + fn update_image_occlusion_note( + &self, + input: pb::image_occlusion::UpdateImageOcclusionNoteRequest, + ) -> Result { + self.with_col(|col| { + col.update_image_occlusion_note( + input.note_id.into(), + &input.occlusions, + &input.header, + &input.back_extra, + input.tags, + ) + }) + .map(Into::into) + } +} diff --git a/rslib/src/backend/mod.rs b/rslib/src/backend/mod.rs index 0af56e3a6..ba7387d4e 100644 --- a/rslib/src/backend/mod.rs +++ b/rslib/src/backend/mod.rs @@ -16,6 +16,7 @@ mod decks; mod error; mod generic; mod i18n; +mod image_occlusion; mod import_export; mod links; mod media; @@ -48,6 +49,7 @@ use self::config::ConfigService; use self::deckconfig::DeckConfigService; use self::decks::DecksService; use self::i18n::I18nService; +use self::image_occlusion::ImageOcclusionService; use self::import_export::ImportExportService; use self::links::LinksService; use self::media::MediaService; @@ -142,6 +144,9 @@ impl Backend { ServiceIndex::Collection => CollectionService::run_method(self, method, input), ServiceIndex::Cards => CardsService::run_method(self, method, input), ServiceIndex::ImportExport => ImportExportService::run_method(self, method, input), + ServiceIndex::ImageOcclusion => { + ImageOcclusionService::run_method(self, method, input) + } }) .map_err(|err| { let backend_err = err.into_protobuf(&self.tr); diff --git a/rslib/src/cloze.rs b/rslib/src/cloze.rs index 1e091e533..2983c5b4a 100644 --- a/rslib/src/cloze.rs +++ b/rslib/src/cloze.rs @@ -15,6 +15,7 @@ use nom::IResult; use regex::Captures; use regex::Regex; +use crate::image_occlusion::imageocclusion::get_image_cloze_data; use crate::latex::contains_latex; use crate::template::RenderContext; use crate::text::strip_html_preserving_entities; @@ -138,6 +139,13 @@ impl ExtractedCloze<'_> { buf.into() } + + /// If cloze starts with image-occlusion:, return the text following that. + fn image_occlusion(&self) -> Option<&str> { + let Some(first_node) = self.nodes.get(0) else { return None }; + let TextOrCloze::Text(text) = first_node else { return None }; + text.strip_prefix("image-occlusion:") + } } fn parse_text_with_clozes(text: &str) -> Vec> { @@ -212,6 +220,14 @@ fn reveal_cloze( ) { let active = cloze.ordinal == cloze_ord; *active_cloze_found_in_text |= active; + if let Some(image_occlusion_text) = cloze.image_occlusion() { + buf.push_str(&render_image_occlusion( + image_occlusion_text, + question, + active, + )); + return; + } match (question, active) { (true, true) => { // question side with active cloze; all inner content is elided @@ -275,6 +291,22 @@ fn reveal_cloze( } } +fn render_image_occlusion(text: &str, question_side: bool, active: bool) -> String { + if question_side && active { + format!( + r#"
"#, + &get_image_cloze_data(text) + ) + } else if !active { + format!( + r#"
"#, + &get_image_cloze_data(text) + ) + } else { + "".into() + } +} + pub fn reveal_cloze_text(text: &str, cloze_ord: u16, question: bool) -> Cow { let mut buf = String::new(); let mut active_cloze_found_in_text = false; @@ -530,4 +562,18 @@ mod test { fn non_latin() { assert!(cloze_numbers_in_string("öaöaöööaö").is_empty()); } + + #[test] + fn image_cloze() { + assert_eq!( + reveal_cloze_text( + "{{c1::image-occlusion:rect:left=10.0:top=20:width=30:height=10}}", + 1, + true + ), + format!( + r#"
"#, + ) + ); + } } diff --git a/rslib/src/error/file_io.rs b/rslib/src/error/file_io.rs index 2200a179a..bc3689ac8 100644 --- a/rslib/src/error/file_io.rs +++ b/rslib/src/error/file_io.rs @@ -33,6 +33,7 @@ pub enum FileOp { CopyFrom(PathBuf), Persist, Sync, + Metadata, /// For legacy errors without any context. Unknown, } @@ -57,6 +58,7 @@ impl FileIoError { FileOp::CopyFrom(p) => format!("copy from '{}' to", p.to_string_lossy()), FileOp::Persist => "persist".into(), FileOp::Sync => "sync".into(), + FileOp::Metadata => "get metadata".into(), }, self.path.to_string_lossy(), self.source diff --git a/rslib/src/image_occlusion/image_occlusion_styling.css b/rslib/src/image_occlusion/image_occlusion_styling.css new file mode 100644 index 000000000..ac8a7e25b --- /dev/null +++ b/rslib/src/image_occlusion/image_occlusion_styling.css @@ -0,0 +1,41 @@ +.image-occlusion-canvas { + --inactive-shape-color: #ffeba2; + --active-shape-color: #ff8e8e; + --inactive-shape-border: 1px #212121; + --active-shape-border: 1px #212121; +} + +.card { + font-family: arial; + font-size: 20px; + text-align: center; + color: black; + background-color: white; +} + +.cloze { + font-weight: bold; + color: blue; +} + +.nightMode .cloze { + color: lightblue; +} + +#container { + position: relative; +} + +img { + position: absolute; + top: 0; + left: 50%; + transform: translate(-50%, 0); +} + +#canvas { + position: absolute; + top: 0; + left: 50%; + transform: translate(-50%, 0); +} diff --git a/rslib/src/image_occlusion/imagedata.rs b/rslib/src/image_occlusion/imagedata.rs new file mode 100644 index 000000000..6e4878d9a --- /dev/null +++ b/rslib/src/image_occlusion/imagedata.rs @@ -0,0 +1,246 @@ +// Copyright: Ankitects Pty Ltd and contributors +// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html + +use std::path::Path; +use std::path::PathBuf; +use std::sync::Arc; + +use regex::Regex; + +use crate::io::metadata; +use crate::io::read_file; +use crate::media::MediaManager; +use crate::notetype::CardGenContext; +use crate::notetype::Notetype; +use crate::notetype::NotetypeConfig; +use crate::pb::image_occlusion::image_cloze_note_response::Value; +use crate::pb::image_occlusion::ImageClozeNote; +use crate::pb::image_occlusion::ImageClozeNoteResponse; +pub use crate::pb::image_occlusion::ImageData; +use crate::prelude::*; + +impl Collection { + pub fn get_image_for_occlusion(&mut self, path: &str) -> Result { + let mut metadata = ImageData { + ..Default::default() + }; + metadata.data = read_file(path)?; + Ok(metadata) + } + + #[allow(clippy::too_many_arguments)] + pub fn add_image_occlusion_note( + &mut self, + image_path: &str, + occlusions: &str, + header: &str, + back_extra: &str, + tags: Vec, + ) -> Result> { + // image file + let image_bytes = read_file(image_path)?; + let image_filename = Path::new(&image_path) + .file_name() + .or_not_found("expected filename")? + .to_str() + .unwrap() + .to_string(); + + let mgr = MediaManager::new(&self.media_folder, &self.media_db)?; + let actual_image_name_after_adding = mgr.add_file(&image_filename, &image_bytes)?; + + let image_tag = format!( + "", + &actual_image_name_after_adding + ); + + let current_deck = self.get_current_deck()?; + self.transact(Op::ImageOcclusion, |col| { + let nt = col.get_or_create_io_notetype()?; + + let mut note = nt.new_note(); + note.set_field(0, occlusions)?; + note.set_field(1, &image_tag)?; + note.set_field(2, header)?; + note.set_field(3, back_extra)?; + note.tags = tags; + + let last_deck = col.get_last_deck_added_to_for_notetype(note.notetype_id); + let ctx = CardGenContext::new(nt.as_ref(), last_deck, col.usn()?); + let norm = col.get_config_bool(BoolKey::NormalizeNoteText); + col.add_note_inner(&ctx, &mut note, current_deck.id, norm)?; + + Ok(()) + }) + } + + fn get_or_create_io_notetype(&mut self) -> Result> { + let tr = &self.tr; + let name = format!("{}", tr.notetypes_image_occlusion_name()); + let nt = match self.get_notetype_by_name(&name)? { + Some(nt) => nt, + None => { + self.add_io_notetype()?; + if let Some(nt) = self.get_notetype_by_name(&name)? { + nt + } else { + return Err(AnkiError::TemplateError { + info: "IO notetype not found".to_string(), + }); + } + } + }; + if nt.fields.len() < 4 { + Err(AnkiError::TemplateError { + info: "IO notetype must have 4+ fields".to_string(), + }) + } else { + Ok(nt) + } + } + + fn add_io_notetype(&mut self) -> Result<()> { + let usn = self.usn()?; + let mut nt = self.image_occlusion_notetype(); + self.add_notetype_inner(&mut nt, usn, false)?; + Ok(()) + } + + pub fn get_image_cloze_note(&mut self, note_id: NoteId) -> Result { + let value = match self.get_image_cloze_note_inner(note_id) { + Ok(note) => Value::Note(note), + Err(err) => Value::Error(format!("{:?}", err)), + }; + Ok(ImageClozeNoteResponse { value: Some(value) }) + } + + pub fn get_image_cloze_note_inner(&mut self, note_id: NoteId) -> Result { + let note = self.storage.get_note(note_id)?.or_not_found(note_id)?; + let mut cloze_note = ImageClozeNote::default(); + + let fields = note.fields(); + if fields.len() < 4 { + invalid_input!("Note does not have 4 fields"); + } + + cloze_note.occlusions = fields[0].clone(); + cloze_note.header = fields[2].clone(); + cloze_note.back_extra = fields[3].clone(); + cloze_note.image_data = "".into(); + cloze_note.tags = note.tags.clone(); + + let image_file_name = fields[1].clone(); + let src = self + .extract_img_src(&image_file_name) + .unwrap_or_else(|| "".to_owned()); + let final_path = self.media_folder.join(src); + + if self.is_image_file(&final_path)? { + cloze_note.image_data = read_file(&final_path)?; + } + + Ok(cloze_note) + } + + pub fn update_image_occlusion_note( + &mut self, + note_id: NoteId, + occlusions: &str, + header: &str, + back_extra: &str, + tags: Vec, + ) -> Result> { + let mut note = self.storage.get_note(note_id)?.or_not_found(note_id)?; + self.transact(Op::ImageOcclusion, |col| { + note.set_field(0, occlusions)?; + note.set_field(2, header)?; + note.set_field(3, back_extra)?; + note.tags = tags; + col.update_note_inner(&mut note)?; + Ok(()) + }) + } + + fn extract_img_src(&mut self, html: &str) -> Option { + let re = Regex::new(r#"]*src\s*=\s*"([^"]+)"#).unwrap(); + re.captures(html).map(|cap| cap[1].to_owned()) + } + + fn is_image_file(&mut self, path: &PathBuf) -> Result { + let file_path = Path::new(&path); + let supported_extensions = vec![ + "jpg", "jpeg", "png", "tif", "tiff", "gif", "svg", "webp", "ico", + ]; + + if file_path.exists() { + let meta = metadata(file_path)?; + if meta.is_file() { + if let Some(ext_osstr) = file_path.extension() { + if let Some(ext_str) = ext_osstr.to_str() { + if supported_extensions.contains(&ext_str) { + return Ok(true); + } + } + } + } + } + + Ok(false) + } + + fn image_occlusion_notetype(&mut self) -> Notetype { + let tr = &self.tr; + const IMAGE_CLOZE_CSS: &str = include_str!("image_occlusion_styling.css"); + let mut nt = Notetype { + name: tr.notetypes_image_occlusion_name().into(), + config: NotetypeConfig::new_cloze(), + ..Default::default() + }; + nt.config.css = IMAGE_CLOZE_CSS.to_string(); + let occlusion = tr.notetypes_occlusion(); + nt.add_field(occlusion.as_ref()); + let image = tr.notetypes_image(); + nt.add_field(image.as_ref()); + let header = tr.notetypes_header(); + nt.add_field(header.as_ref()); + let back_extra = tr.notetypes_back_extra_field(); + nt.add_field(back_extra.as_ref()); + let comments = tr.notetypes_comments_field(); + nt.add_field(comments.as_ref()); + let qfmt = format!( + "
{{{{cloze:{}}}}}
+
+ {{{{{}}}}} + +
+
+ +", + occlusion, + image, + tr.notetypes_error_loading_image_occlusion(), + ); + let afmt = format!( + "{{{{{}}}}} +{} + +
+{{{{{}}}}} +
+{{{{{}}}}}", + header, + qfmt, + tr.notetypes_toggle_masks(), + back_extra, + comments, + ); + nt.add_template(nt.name.clone(), qfmt, afmt); + nt + } +} diff --git a/rslib/src/image_occlusion/imageocclusion.rs b/rslib/src/image_occlusion/imageocclusion.rs new file mode 100644 index 000000000..f2c3b41ee --- /dev/null +++ b/rslib/src/image_occlusion/imageocclusion.rs @@ -0,0 +1,107 @@ +// Copyright: Ankitects Pty Ltd and contributors +// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html + +use std::fmt::Write; + +// split following +// text = "rect:399.01,99.52,167.09,33.78:fill=#0a2cee:stroke=1" +// with +// result = "data-shape="rect" data-left="399.01" data-top="99.52" data-width="167.09" data-height="33.78" data-fill="\#0a2cee" data-stroke="1"" +pub fn get_image_cloze_data(text: &str) -> String { + let mut result = String::new(); + let parts: Vec<&str> = text.split(':').collect(); + + if parts.len() >= 2 { + if !parts[0].is_empty() + && (parts[0] == "rect" || parts[0] == "ellipse" || parts[0] == "polygon") + { + result.push_str(&format!("data-shape=\"{}\" ", parts[0])); + } + + for part in parts[1..].iter() { + let values: Vec<&str> = part.split('=').collect(); + if values.len() >= 2 { + match values[0] { + "left" => { + if !values[1].is_empty() { + result.push_str(&format!("data-left=\"{}\" ", values[1])); + } + } + "top" => { + if !values[1].is_empty() { + result.push_str(&format!("data-top=\"{}\" ", values[1])); + } + } + "width" => { + if !is_empty_or_zero(values[1]) { + result.push_str(&format!("data-width=\"{}\" ", values[1])); + } + } + "height" => { + if !is_empty_or_zero(values[1]) { + result.push_str(&format!("data-height=\"{}\" ", values[1])); + } + } + "rx" => { + if !is_empty_or_zero(values[1]) { + result.push_str(&format!("data-rx=\"{}\" ", values[1])); + } + } + "ry" => { + if !is_empty_or_zero(values[1]) { + result.push_str(&format!("data-ry=\"{}\" ", values[1])); + } + } + "points" => { + if !values[1].is_empty() { + let mut point_str = String::new(); + for point_pair in values[1].split(' ') { + let Some((x, y)) = point_pair.split_once(',') else { continue }; + write!(&mut point_str, "[{},{}],", x, y).unwrap(); + } + // remove the trailing comma + point_str.pop(); + if !point_str.is_empty() { + result.push_str(&format!("data-points=\"[{}]\" ", point_str)); + } + } + } + "hideinactive" => { + if !values[1].is_empty() { + result.push_str(&format!("data-hideinactive=\"{}\" ", values[1])); + } + } + _ => {} + } + } + } + } + + result +} + +fn is_empty_or_zero(text: &str) -> bool { + text.is_empty() || text == "0" +} + +//---------------------------------------- +// Tests +//---------------------------------------- + +#[test] +fn test_get_image_cloze_data() { + assert_eq!( + get_image_cloze_data("rect:left=10:top=20:width=30:height=10"), + format!( + r#"data-shape="rect" data-left="10" data-top="20" data-width="30" data-height="10" "#, + ) + ); + assert_eq!( + get_image_cloze_data("ellipse:left=15:top=20:width=10:height=20:rx=10:ry=5"), + r#"data-shape="ellipse" data-left="15" data-top="20" data-width="10" data-height="20" data-rx="10" data-ry="5" "#, + ); + assert_eq!( + get_image_cloze_data("polygon:points=0,0 10,10 20,0"), + r#"data-shape="polygon" data-points="[[0,0],[10,10],[20,0]]" "#, + ); +} diff --git a/rslib/src/image_occlusion/mod.rs b/rslib/src/image_occlusion/mod.rs new file mode 100644 index 000000000..d71f09987 --- /dev/null +++ b/rslib/src/image_occlusion/mod.rs @@ -0,0 +1,5 @@ +// Copyright: Ankitects Pty Ltd and contributors +// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html + +pub mod imagedata; +pub mod imageocclusion; diff --git a/rslib/src/io.rs b/rslib/src/io.rs index 0e2243be7..b97715650 100644 --- a/rslib/src/io.rs +++ b/rslib/src/io.rs @@ -93,6 +93,14 @@ fn read_locked_db_file_inner(path: impl AsRef) -> std::io::Result> Ok(buf) } +/// See [std::fs::metadata]. +pub(crate) fn metadata(path: impl AsRef) -> Result { + std::fs::metadata(&path).context(FileIoSnafu { + path: path.as_ref(), + op: FileOp::Metadata, + }) +} + pub(crate) fn new_tempfile() -> Result { NamedTempFile::new().context(FileIoSnafu { path: std::env::temp_dir(), diff --git a/rslib/src/lib.rs b/rslib/src/lib.rs index bf7582263..eefeb01c6 100644 --- a/rslib/src/lib.rs +++ b/rslib/src/lib.rs @@ -17,6 +17,7 @@ pub mod decks; pub mod error; pub mod findreplace; pub mod i18n; +pub mod image_occlusion; pub mod import_export; mod io; pub mod latex; diff --git a/rslib/src/notetype/mod.rs b/rslib/src/notetype/mod.rs index d017e9673..aa5fd7985 100644 --- a/rslib/src/notetype/mod.rs +++ b/rslib/src/notetype/mod.rs @@ -9,7 +9,7 @@ mod notetypechange; mod render; mod schema11; mod schemachange; -mod stock; +pub(crate) mod stock; mod templates; pub(crate) mod undo; diff --git a/rslib/src/ops.rs b/rslib/src/ops.rs index 3e5842e8f..cd1826cc1 100644 --- a/rslib/src/ops.rs +++ b/rslib/src/ops.rs @@ -17,6 +17,7 @@ pub enum Op { CreateCustomStudy, EmptyFilteredDeck, FindAndReplace, + ImageOcclusion, Import, RebuildFilteredDeck, RemoveDeck, @@ -90,6 +91,7 @@ impl Op { Op::Custom(name) => name.into(), Op::ChangeNotetype => tr.browsing_change_notetype(), Op::SkipUndo => return "".to_string(), + Op::ImageOcclusion => tr.notetypes_image_occlusion_name(), } .into() } diff --git a/rslib/src/pb.rs b/rslib/src/pb.rs index bcb2f34c4..017e689ca 100644 --- a/rslib/src/pb.rs +++ b/rslib/src/pb.rs @@ -19,6 +19,7 @@ protobuf!(deckconfig, "deckconfig"); protobuf!(decks, "decks"); protobuf!(generic, "generic"); protobuf!(i18n, "i18n"); +protobuf!(image_occlusion, "image_occlusion"); protobuf!(import_export, "import_export"); protobuf!(links, "links"); protobuf!(media, "media"); diff --git a/ts/image-occlusion/ImageOcclusionPage.svelte b/ts/image-occlusion/ImageOcclusionPage.svelte new file mode 100644 index 000000000..02502953d --- /dev/null +++ b/ts/image-occlusion/ImageOcclusionPage.svelte @@ -0,0 +1,106 @@ + + + + +
    + {#each items as item} +
  • + {item.label} +
  • + {/each} +
+ + + + +
+ + diff --git a/ts/image-occlusion/MaskEditor.svelte b/ts/image-occlusion/MaskEditor.svelte new file mode 100644 index 000000000..a598246d7 --- /dev/null +++ b/ts/image-occlusion/MaskEditor.svelte @@ -0,0 +1,81 @@ + + + + +
+
+ + + +
+
+ + diff --git a/ts/image-occlusion/Notes.svelte b/ts/image-occlusion/Notes.svelte new file mode 100644 index 000000000..71d308e54 --- /dev/null +++ b/ts/image-occlusion/Notes.svelte @@ -0,0 +1,93 @@ + + + +
+ +
+ + + {#each notesFields as field} + + + + {field.title} + + + +
+
{ + field.textareaValue = field.divValue; + }} + contenteditable + /> +