mirror of
				https://github.com/mealie-recipes/mealie.git
				synced 2025-10-31 10:13:32 -04:00 
			
		
		
		
	feat (WIP): bring png OCR scanning support (#1670)
* Add pytesseract * Add simple ocr endpoint replace extension argument * feat/ocr-editor gui * fix frontend linting issues * Add service unit tests * Add split text modes & single ingredient/instruction editing * make split mode really reactive * Remove default step and ingredient * make the linter haappy * Accept only image uploads * Add automatic recipe title suggestion * Correct regex * fix incorrect array.map method usage * make the linter happy again * Swap route to use asset name * Rearange buttons * fix test data * feat: Allow making image the recipe image * Add translation * Make the linter happy * Restrict function setPropertyValueByPath generic * Restrict template literal type * Add a more friendly icon to creation page * update poetry lock file * Correct sloppy ocr classes * Make MyPy happy * Rewrite safer tests * Add tesseract to backend test CI container dependencies * Make canvas element a component global * Remove unwanted spaces in selected text * Add way to know if recipe was created with ocr * Access to ocr-editor for ocr recipes * Update Alembic revision * Make the frontend build * Fix scrolling offset bug * Allow creation of recipes with custom settings * Fix rebasing mistakes * Add format_tsv_output test * Exclude the tests data directory only * Enforce camelCase for frontend functions * Remove import of unused component * Fix type and class initialization * Add multi-language support * Highlight words in mount * Fix image ratio bug * Better ocr creation page * Revert awkward feature to scroll in Selection mode * Rebasing alembic migrations sux * Remove obsolete getShared function * Add function docstring * Move down ocr creation option * Make toolbar icons more generic * Show help at the bottom of the page * move ocr types to own file * Use template ref for the canvas * Use i18n.tc to get strings directly * Correct naming mistake * Move Ocr editor to own directory * Create Ocr Editor parts * Safeguard recipe properties access * Add loading frontend animation due to longer request time * minor cleanup chores Co-authored-by: Miroito <alban.vachette@gmail.com>
This commit is contained in:
		
							
								
								
									
										2
									
								
								.github/workflows/partial-backend.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										2
									
								
								.github/workflows/partial-backend.yml
									
									
									
									
										vendored
									
									
								
							| @@ -62,7 +62,7 @@ jobs: | |||||||
|       - name: Install dependencies |       - name: Install dependencies | ||||||
|         run: | |         run: | | ||||||
|           sudo apt-get update |           sudo apt-get update | ||||||
|           sudo apt-get install libsasl2-dev libldap2-dev libssl-dev |           sudo apt-get install libsasl2-dev libldap2-dev libssl-dev tesseract-ocr-all | ||||||
|           poetry install |           poetry install | ||||||
|           poetry add "psycopg2-binary==2.8.6" |           poetry add "psycopg2-binary==2.8.6" | ||||||
|         if: steps.cached-poetry-dependencies.outputs.cache-hit != 'true' |         if: steps.cached-poetry-dependencies.outputs.cache-hit != 'true' | ||||||
|   | |||||||
| @@ -9,6 +9,7 @@ repos: | |||||||
|       - id: check-toml |       - id: check-toml | ||||||
|       - id: end-of-file-fixer |       - id: end-of-file-fixer | ||||||
|       - id: trailing-whitespace |       - id: trailing-whitespace | ||||||
|  |         exclude: ^tests/data/ | ||||||
|   - repo: https://github.com/sondrelg/pep585-upgrade |   - repo: https://github.com/sondrelg/pep585-upgrade | ||||||
|     rev: "v1.0.1" # Use the sha / tag you want to point at |     rev: "v1.0.1" # Use the sha / tag you want to point at | ||||||
|     hooks: |     hooks: | ||||||
|   | |||||||
| @@ -0,0 +1,27 @@ | |||||||
|  | """Add is_ocr_recipe column to recipes | ||||||
|  |  | ||||||
|  | Revision ID: 089bfa50d0ed | ||||||
|  | Revises: f30cf048c228 | ||||||
|  | Create Date: 2022-08-05 17:07:07.389271 | ||||||
|  |  | ||||||
|  | """ | ||||||
|  | import sqlalchemy as sa | ||||||
|  |  | ||||||
|  | from alembic import op | ||||||
|  |  | ||||||
|  | # revision identifiers, used by Alembic. | ||||||
|  | revision = "089bfa50d0ed" | ||||||
|  | down_revision = "188374910655" | ||||||
|  | branch_labels = None | ||||||
|  | depends_on = None | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def upgrade(): | ||||||
|  |     op.add_column("recipes", sa.Column("is_ocr_recipe", sa.Boolean(), default=False, nullable=True)) | ||||||
|  |     op.execute("UPDATE recipes SET is_ocr_recipe = FALSE") | ||||||
|  |     #  SQLITE does not support ALTER COLUMN, so the column will stay nullable to prevent making this migration a mess | ||||||
|  |     #  The Recipe pydantic model and the SQL server use False as default value anyway for this column so Null should be a very rare sight | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def downgrade(): | ||||||
|  |     op.drop_column("recipes", "is_ocr_recipe") | ||||||
							
								
								
									
										18
									
								
								frontend/api/class-interfaces/ocr.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										18
									
								
								frontend/api/class-interfaces/ocr.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,18 @@ | |||||||
|  | import { BaseAPI } from "~/api/_base"; | ||||||
|  |  | ||||||
|  | const prefix = "/api"; | ||||||
|  |  | ||||||
|  | export class OcrAPI extends BaseAPI { | ||||||
|  |  | ||||||
|  |   // Currently unused in favor for the endpoint using asset names | ||||||
|  |   async fileToTsv(file: File) { | ||||||
|  |     const formData = new FormData(); | ||||||
|  |     formData.append("file", file); | ||||||
|  |     return await this.requests.post(`${prefix}/ocr/file-to-tsv`, formData); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   async assetToTsv(recipeSlug: string, assetName: string) { | ||||||
|  |     return await this.requests.post(`${prefix}/ocr/asset-to-tsv`, { recipeSlug, assetName }); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  | } | ||||||
| @@ -34,6 +34,7 @@ const routes = { | |||||||
|   recipesCategory: `${prefix}/recipes/category`, |   recipesCategory: `${prefix}/recipes/category`, | ||||||
|   recipesParseIngredient: `${prefix}/parser/ingredient`, |   recipesParseIngredient: `${prefix}/parser/ingredient`, | ||||||
|   recipesParseIngredients: `${prefix}/parser/ingredients`, |   recipesParseIngredients: `${prefix}/parser/ingredients`, | ||||||
|  |   recipesCreateFromOcr: `${prefix}/recipes/create-ocr`, | ||||||
|  |  | ||||||
|   recipesRecipeSlug: (recipe_slug: string) => `${prefix}/recipes/${recipe_slug}`, |   recipesRecipeSlug: (recipe_slug: string) => `${prefix}/recipes/${recipe_slug}`, | ||||||
|   recipesRecipeSlugExport: (recipe_slug: string) => `${prefix}/recipes/${recipe_slug}/exports`, |   recipesRecipeSlugExport: (recipe_slug: string) => `${prefix}/recipes/${recipe_slug}/exports`, | ||||||
| @@ -116,4 +117,13 @@ export class RecipeAPI extends BaseCRUDAPI<CreateRecipe, Recipe, Recipe> { | |||||||
|   getZipRedirectUrl(recipeSlug: string, token: string) { |   getZipRedirectUrl(recipeSlug: string, token: string) { | ||||||
|     return `${routes.recipesRecipeSlugExportZip(recipeSlug)}?token=${token}`; |     return `${routes.recipesRecipeSlugExportZip(recipeSlug)}?token=${token}`; | ||||||
|   } |   } | ||||||
|  |  | ||||||
|  |   async createFromOcr(file: File, makeFileRecipeImage: boolean) { | ||||||
|  |     const formData = new FormData(); | ||||||
|  |     formData.append("file", file); | ||||||
|  |     formData.append("extension", file.name.split(".").pop() ?? ""); | ||||||
|  |     formData.append("makefilerecipeimage", String(makeFileRecipeImage)); | ||||||
|  |  | ||||||
|  |     return await this.requests.post(routes.recipesCreateFromOcr, formData); | ||||||
|  |   } | ||||||
| } | } | ||||||
|   | |||||||
| @@ -24,6 +24,7 @@ import { MultiPurposeLabelsApi } from "./class-interfaces/group-multiple-purpose | |||||||
| import { GroupEventNotifierApi } from "./class-interfaces/group-event-notifier"; | import { GroupEventNotifierApi } from "./class-interfaces/group-event-notifier"; | ||||||
| import { MealPlanRulesApi } from "./class-interfaces/group-mealplan-rules"; | import { MealPlanRulesApi } from "./class-interfaces/group-mealplan-rules"; | ||||||
| import { GroupDataSeederApi } from "./class-interfaces/group-seeder"; | import { GroupDataSeederApi } from "./class-interfaces/group-seeder"; | ||||||
|  | import {OcrAPI} from "./class-interfaces/ocr"; | ||||||
| import { ApiRequestInstance } from "~/types/api"; | import { ApiRequestInstance } from "~/types/api"; | ||||||
|  |  | ||||||
| class Api { | class Api { | ||||||
| @@ -52,6 +53,7 @@ class Api { | |||||||
|   public groupEventNotifier: GroupEventNotifierApi; |   public groupEventNotifier: GroupEventNotifierApi; | ||||||
|   public upload: UploadFile; |   public upload: UploadFile; | ||||||
|   public seeders: GroupDataSeederApi; |   public seeders: GroupDataSeederApi; | ||||||
|  |   public ocr: OcrAPI; | ||||||
|  |  | ||||||
|   constructor(requests: ApiRequestInstance) { |   constructor(requests: ApiRequestInstance) { | ||||||
|     // Recipes |     // Recipes | ||||||
| @@ -90,6 +92,9 @@ class Api { | |||||||
|     this.bulk = new BulkActionsAPI(requests); |     this.bulk = new BulkActionsAPI(requests); | ||||||
|     this.groupEventNotifier = new GroupEventNotifierApi(requests); |     this.groupEventNotifier = new GroupEventNotifierApi(requests); | ||||||
|  |  | ||||||
|  |     // ocr | ||||||
|  |     this.ocr = new OcrAPI(requests); | ||||||
|  |  | ||||||
|     Object.freeze(this); |     Object.freeze(this); | ||||||
|   } |   } | ||||||
| } | } | ||||||
|   | |||||||
| @@ -90,6 +90,7 @@ const SAVE_EVENT = "save"; | |||||||
| const DELETE_EVENT = "delete"; | const DELETE_EVENT = "delete"; | ||||||
| const CLOSE_EVENT = "close"; | const CLOSE_EVENT = "close"; | ||||||
| const JSON_EVENT = "json"; | const JSON_EVENT = "json"; | ||||||
|  | const OCR_EVENT = "ocr"; | ||||||
|  |  | ||||||
| export default defineComponent({ | export default defineComponent({ | ||||||
|   components: { RecipeContextMenu, RecipeFavoriteBadge }, |   components: { RecipeContextMenu, RecipeFavoriteBadge }, | ||||||
| @@ -122,8 +123,12 @@ export default defineComponent({ | |||||||
|       type: Boolean, |       type: Boolean, | ||||||
|       default: false, |       default: false, | ||||||
|     }, |     }, | ||||||
|  |     showOcrButton: { | ||||||
|  |       type: Boolean, | ||||||
|  |       default: false, | ||||||
|     }, |     }, | ||||||
|   setup(_, context) { |   }, | ||||||
|  |   setup(props, context) { | ||||||
|     const deleteDialog = ref(false); |     const deleteDialog = ref(false); | ||||||
|  |  | ||||||
|     const { i18n, $globals } = useContext(); |     const { i18n, $globals } = useContext(); | ||||||
| @@ -154,22 +159,26 @@ export default defineComponent({ | |||||||
|       }, |       }, | ||||||
|     ]; |     ]; | ||||||
|  |  | ||||||
|  |     if (props.showOcrButton) { | ||||||
|  |       editorButtons.splice(2, 0, { | ||||||
|  |         text: i18n.t("ocr-editor.ocr-editor"), | ||||||
|  |         icon: $globals.icons.eye, | ||||||
|  |         event: OCR_EVENT, | ||||||
|  |         color: "accent", | ||||||
|  |       }); | ||||||
|  |     } | ||||||
|  |  | ||||||
|     function emitHandler(event: string) { |     function emitHandler(event: string) { | ||||||
|       switch (event) { |       switch (event) { | ||||||
|         case CLOSE_EVENT: |         case CLOSE_EVENT: | ||||||
|           context.emit(CLOSE_EVENT); |           context.emit(CLOSE_EVENT); | ||||||
|           context.emit("input", false); |           context.emit("input", false); | ||||||
|           break; |           break; | ||||||
|         case SAVE_EVENT: |  | ||||||
|           context.emit(SAVE_EVENT); |  | ||||||
|           break; |  | ||||||
|         case JSON_EVENT: |  | ||||||
|           context.emit(JSON_EVENT); |  | ||||||
|           break; |  | ||||||
|         case DELETE_EVENT: |         case DELETE_EVENT: | ||||||
|           deleteDialog.value = true; |           deleteDialog.value = true; | ||||||
|           break; |           break; | ||||||
|         default: |         default: | ||||||
|  |           context.emit(event); | ||||||
|           break; |           break; | ||||||
|       } |       } | ||||||
|     } |     } | ||||||
|   | |||||||
| @@ -2,7 +2,7 @@ | |||||||
|   <div class="text-center"> |   <div class="text-center"> | ||||||
|     <v-dialog v-model="dialog" width="800"> |     <v-dialog v-model="dialog" width="800"> | ||||||
|       <template #activator="{ on, attrs }"> |       <template #activator="{ on, attrs }"> | ||||||
|         <BaseButton v-bind="attrs" v-on="on" @click="inputText = ''"> |         <BaseButton v-bind="attrs" v-on="on" @click="inputText = inputTextProp"> | ||||||
|           {{ $t("new-recipe.bulk-add") }} |           {{ $t("new-recipe.bulk-add") }} | ||||||
|         </BaseButton> |         </BaseButton> | ||||||
|       </template> |       </template> | ||||||
| @@ -58,10 +58,17 @@ | |||||||
| <script lang="ts"> | <script lang="ts"> | ||||||
| import { reactive, toRefs, defineComponent, useContext } from "@nuxtjs/composition-api"; | import { reactive, toRefs, defineComponent, useContext } from "@nuxtjs/composition-api"; | ||||||
| export default defineComponent({ | export default defineComponent({ | ||||||
|   setup(_, context) { |   props: { | ||||||
|  |     inputTextProp: { | ||||||
|  |       type: String, | ||||||
|  |       required: false, | ||||||
|  |       default: "", | ||||||
|  |     }, | ||||||
|  |   }, | ||||||
|  |   setup(props, context) { | ||||||
|     const state = reactive({ |     const state = reactive({ | ||||||
|       dialog: false, |       dialog: false, | ||||||
|       inputText: "", |       inputText: props.inputTextProp, | ||||||
|     }); |     }); | ||||||
|  |  | ||||||
|     function splitText() { |     function splitText() { | ||||||
|   | |||||||
| @@ -8,6 +8,7 @@ | |||||||
|       class="mx-1 mt-3 mb-4" |       class="mx-1 mt-3 mb-4" | ||||||
|       :placeholder="$t('recipe.section-title')" |       :placeholder="$t('recipe.section-title')" | ||||||
|       style="max-width: 500px" |       style="max-width: 500px" | ||||||
|  |       @click="$emit('clickIngredientField', 'title')" | ||||||
|     > |     > | ||||||
|     </v-text-field> |     </v-text-field> | ||||||
|     <v-row :no-gutters="$vuetify.breakpoint.mdAndUp" dense class="d-flex flex-wrap my-1"> |     <v-row :no-gutters="$vuetify.breakpoint.mdAndUp" dense class="d-flex flex-wrap my-1"> | ||||||
| @@ -81,7 +82,15 @@ | |||||||
|       </v-col> |       </v-col> | ||||||
|       <v-col sm="12" md="" cols="12"> |       <v-col sm="12" md="" cols="12"> | ||||||
|         <div class="d-flex"> |         <div class="d-flex"> | ||||||
|           <v-text-field v-model="value.note" hide-details dense solo class="mx-1" :placeholder="$t('recipe.notes')"> |           <v-text-field | ||||||
|  |             v-model="value.note" | ||||||
|  |             hide-details | ||||||
|  |             dense | ||||||
|  |             solo | ||||||
|  |             class="mx-1" | ||||||
|  |             :placeholder="$t('recipe.notes')" | ||||||
|  |             @click="$emit('clickIngredientField', 'note')" | ||||||
|  |           > | ||||||
|             <v-icon v-if="disableAmount && $listeners && $listeners.delete" slot="prepend" class="mr-n1 handle"> |             <v-icon v-if="disableAmount && $listeners && $listeners.delete" slot="prepend" class="mr-n1 handle"> | ||||||
|               {{ $globals.icons.arrowUpDown }} |               {{ $globals.icons.arrowUpDown }} | ||||||
|             </v-icon> |             </v-icon> | ||||||
| @@ -93,12 +102,12 @@ | |||||||
|             :buttons="[ |             :buttons="[ | ||||||
|               { |               { | ||||||
|                 icon: $globals.icons.delete, |                 icon: $globals.icons.delete, | ||||||
|                 text: $t('general.delete'), |                 text: $tc('general.delete'), | ||||||
|                 event: 'delete', |                 event: 'delete', | ||||||
|               }, |               }, | ||||||
|               { |               { | ||||||
|                 icon: $globals.icons.dotsVertical, |                 icon: $globals.icons.dotsVertical, | ||||||
|                 text: $t('general.menu'), |                 text: $tc('general.menu'), | ||||||
|                 event: 'open', |                 event: 'open', | ||||||
|                 children: contextMenuOptions, |                 children: contextMenuOptions, | ||||||
|               }, |               }, | ||||||
|   | |||||||
| @@ -176,6 +176,7 @@ | |||||||
|                   blur: imageUploadMode, |                   blur: imageUploadMode, | ||||||
|                 }" |                 }" | ||||||
|                 @drop.stop.prevent="handleImageDrop(index, $event)" |                 @drop.stop.prevent="handleImageDrop(index, $event)" | ||||||
|  |                 @click="$emit('clickInstructionField', `${index}.text`)" | ||||||
|               > |               > | ||||||
|                 <MarkdownEditor |                 <MarkdownEditor | ||||||
|                   v-model="value[index]['text']" |                   v-model="value[index]['text']" | ||||||
|   | |||||||
| @@ -0,0 +1,381 @@ | |||||||
|  | <template> | ||||||
|  |   <v-container | ||||||
|  |     v-if="recipe && recipe.slug && recipe.settings && recipe.recipeIngredient" | ||||||
|  |     :class="{ | ||||||
|  |       'pa-0': $vuetify.breakpoint.smAndDown, | ||||||
|  |     }" | ||||||
|  |   > | ||||||
|  |     <BannerExperimental /> | ||||||
|  |  | ||||||
|  |     <div v-if="loading"> | ||||||
|  |       <v-spacer /> | ||||||
|  |       <v-progress-circular indeterminate class="" color="primary"> </v-progress-circular> | ||||||
|  |       {{ loadingText }} | ||||||
|  |       <v-spacer /> | ||||||
|  |     </div> | ||||||
|  |     <v-row v-if="!loading"> | ||||||
|  |       <v-col cols="12" sm="7" md="7" lg="7"> | ||||||
|  |         <RecipeOcrEditorPageCanvas | ||||||
|  |           :image="canvasImage" | ||||||
|  |           :tsv="tsv" | ||||||
|  |           @setText="canvasSetText" | ||||||
|  |           @update-recipe="updateRecipe" | ||||||
|  |           @close-editor="closeEditor" | ||||||
|  |           @text-selected="updateSelectedText" | ||||||
|  |         > | ||||||
|  |         </RecipeOcrEditorPageCanvas> | ||||||
|  |  | ||||||
|  |         <RecipeOcrEditorPageHelp /> | ||||||
|  |       </v-col> | ||||||
|  |       <v-col cols="12" sm="5" md="5" lg="5"> | ||||||
|  |         <v-tabs v-model="tab" fixed-tabs> | ||||||
|  |           <v-tab key="header"> | ||||||
|  |             {{ $t("general.recipe") }} | ||||||
|  |           </v-tab> | ||||||
|  |           <v-tab key="ingredients"> | ||||||
|  |             {{ $t("recipe.ingredients") }} | ||||||
|  |           </v-tab> | ||||||
|  |           <v-tab key="instructions"> | ||||||
|  |             {{ $t("recipe.instructions") }} | ||||||
|  |           </v-tab> | ||||||
|  |         </v-tabs> | ||||||
|  |         <v-tabs-items v-model="tab"> | ||||||
|  |           <v-tab-item key="header"> | ||||||
|  |             <v-text-field | ||||||
|  |               v-model="recipe.name" | ||||||
|  |               class="my-3" | ||||||
|  |               :label="$t('recipe.recipe-name')" | ||||||
|  |               :rules="[validators.required]" | ||||||
|  |               @focus="selectedRecipeField = 'name'" | ||||||
|  |             > | ||||||
|  |             </v-text-field> | ||||||
|  |  | ||||||
|  |             <div class="d-flex flex-wrap"> | ||||||
|  |               <v-text-field | ||||||
|  |                 v-model="recipe.totalTime" | ||||||
|  |                 class="mx-2" | ||||||
|  |                 :label="$t('recipe.total-time')" | ||||||
|  |                 @click="selectedRecipeField = 'totalTime'" | ||||||
|  |               ></v-text-field> | ||||||
|  |               <v-text-field | ||||||
|  |                 v-model="recipe.prepTime" | ||||||
|  |                 class="mx-2" | ||||||
|  |                 :label="$t('recipe.prep-time')" | ||||||
|  |                 @click="selectedRecipeField = 'prepTime'" | ||||||
|  |               ></v-text-field> | ||||||
|  |               <v-text-field | ||||||
|  |                 v-model="recipe.performTime" | ||||||
|  |                 class="mx-2" | ||||||
|  |                 :label="$t('recipe.perform-time')" | ||||||
|  |                 @click="selectedRecipeField = 'performTime'" | ||||||
|  |               ></v-text-field> | ||||||
|  |             </div> | ||||||
|  |  | ||||||
|  |             <v-textarea | ||||||
|  |               v-model="recipe.description" | ||||||
|  |               auto-grow | ||||||
|  |               min-height="100" | ||||||
|  |               :label="$t('recipe.description')" | ||||||
|  |               @click="selectedRecipeField = 'description'" | ||||||
|  |             > | ||||||
|  |             </v-textarea> | ||||||
|  |             <v-text-field | ||||||
|  |               v-model="recipe.recipeYield" | ||||||
|  |               dense | ||||||
|  |               :label="$t('recipe.servings')" | ||||||
|  |               @click="selectedRecipeField = 'recipeYield'" | ||||||
|  |             > | ||||||
|  |             </v-text-field> | ||||||
|  |           </v-tab-item> | ||||||
|  |           <v-tab-item key="ingredients"> | ||||||
|  |             <div class="d-flex justify-end mt-2"> | ||||||
|  |               <RecipeDialogBulkAdd class="ml-1 mr-1" :input-text-prop="canvasSelectedText" @bulk-data="addIngredient" /> | ||||||
|  |               <BaseButton @click="addIngredient"> {{ $t("general.new") }} </BaseButton> | ||||||
|  |             </div> | ||||||
|  |             <draggable | ||||||
|  |               v-if="recipe.recipeIngredient.length > 0" | ||||||
|  |               v-model="recipe.recipeIngredient" | ||||||
|  |               handle=".handle" | ||||||
|  |               v-bind="{ | ||||||
|  |                 animation: 200, | ||||||
|  |                 group: 'description', | ||||||
|  |                 disabled: false, | ||||||
|  |                 ghostClass: 'ghost', | ||||||
|  |               }" | ||||||
|  |               @start="drag = true" | ||||||
|  |               @end="drag = false" | ||||||
|  |             > | ||||||
|  |               <TransitionGroup type="transition" :name="!drag ? 'flip-list' : ''"> | ||||||
|  |                 <RecipeIngredientEditor | ||||||
|  |                   v-for="(ingredient, index) in recipe.recipeIngredient" | ||||||
|  |                   :key="ingredient.referenceId" | ||||||
|  |                   v-model="recipe.recipeIngredient[index]" | ||||||
|  |                   class="list-group-item" | ||||||
|  |                   :disable-amount="recipe.settings.disableAmount" | ||||||
|  |                   @delete="recipe.recipeIngredient.splice(index, 1)" | ||||||
|  |                   @clickIngredientField="setSingleIngredient($event, index)" | ||||||
|  |                 /> | ||||||
|  |               </TransitionGroup> | ||||||
|  |             </draggable> | ||||||
|  |           </v-tab-item> | ||||||
|  |           <v-tab-item key="instructions"> | ||||||
|  |             <div class="d-flex justify-end mt-2"> | ||||||
|  |               <RecipeDialogBulkAdd class="ml-1 mr-1" :input-text-prop="canvasSelectedText" @bulk-data="addStep" /> | ||||||
|  |               <BaseButton @click="addStep()"> {{ $t("general.new") }}</BaseButton> | ||||||
|  |             </div> | ||||||
|  |             <RecipeInstructions | ||||||
|  |               v-model="recipe.recipeInstructions" | ||||||
|  |               :ingredients="recipe.recipeIngredient" | ||||||
|  |               :disable-amount="recipe.settings.disableAmount" | ||||||
|  |               :edit="true" | ||||||
|  |               :recipe-id="recipe.id" | ||||||
|  |               :recipe-slug="recipe.slug" | ||||||
|  |               :assets.sync="recipe.assets" | ||||||
|  |               @clickInstructionField="setSingleStep" | ||||||
|  |             /> | ||||||
|  |           </v-tab-item> | ||||||
|  |         </v-tabs-items> | ||||||
|  |       </v-col> | ||||||
|  |     </v-row> | ||||||
|  |   </v-container> | ||||||
|  | </template> | ||||||
|  |  | ||||||
|  | <script lang="ts"> | ||||||
|  | import { defineComponent, ref, onMounted, reactive, toRefs, useRouter } from "@nuxtjs/composition-api"; | ||||||
|  | import { until } from "@vueuse/core"; | ||||||
|  | import { invoke } from "@vueuse/shared"; | ||||||
|  | import draggable from "vuedraggable"; | ||||||
|  | import { useUserApi, useStaticRoutes } from "~/composables/api"; | ||||||
|  | import { OcrTsvResponse } from "~/types/api-types/ocr"; | ||||||
|  | import { validators } from "~/composables/use-validators"; | ||||||
|  | import { Recipe, RecipeIngredient, RecipeStep } from "~/types/api-types/recipe"; | ||||||
|  | import { Paths, Leaves, SelectedRecipeLeaves } from "~/types/ocr-types"; | ||||||
|  | import BannerExperimental from "~/components/global/BannerExperimental.vue"; | ||||||
|  | import RecipeDialogBulkAdd from "~/components/Domain/Recipe/RecipeDialogBulkAdd.vue"; | ||||||
|  | import RecipeInstructions from "~/components/Domain/Recipe/RecipeInstructions.vue"; | ||||||
|  | import RecipeIngredientEditor from "~/components/Domain/Recipe/RecipeIngredientEditor.vue"; | ||||||
|  | import RecipeOcrEditorPageCanvas from "~/components/Domain/Recipe/RecipeOcrEditorPage/RecipeOcrEditorPageParts/RecipeOcrEditorPageCanvas.vue"; | ||||||
|  | import RecipeOcrEditorPageHelp from "~/components/Domain/Recipe/RecipeOcrEditorPage/RecipeOcrEditorPageParts/RecipeOcrEditorPageHelp.vue"; | ||||||
|  | import { uuid4 } from "~/composables/use-utils"; | ||||||
|  | import { NoUndefinedField } from "~/types/api"; | ||||||
|  |  | ||||||
|  | export default defineComponent({ | ||||||
|  |   components: { | ||||||
|  |     RecipeIngredientEditor, | ||||||
|  |     draggable, | ||||||
|  |     BannerExperimental, | ||||||
|  |     RecipeDialogBulkAdd, | ||||||
|  |     RecipeInstructions, | ||||||
|  |     RecipeOcrEditorPageCanvas, | ||||||
|  |     RecipeOcrEditorPageHelp, | ||||||
|  |   }, | ||||||
|  |   props: { | ||||||
|  |     recipe: { | ||||||
|  |       type: Object as () => NoUndefinedField<Recipe>, | ||||||
|  |       required: true, | ||||||
|  |     }, | ||||||
|  |   }, | ||||||
|  |   setup(props) { | ||||||
|  |     const router = useRouter(); | ||||||
|  |     const api = useUserApi(); | ||||||
|  |  | ||||||
|  |     const tsv = ref<OcrTsvResponse[]>([]); | ||||||
|  |  | ||||||
|  |     const drag = ref(false); | ||||||
|  |  | ||||||
|  |     const { recipeAssetPath } = useStaticRoutes(); | ||||||
|  |  | ||||||
|  |     function assetURL(assetName: string) { | ||||||
|  |       return recipeAssetPath(props.recipe.id, assetName); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     const state = reactive({ | ||||||
|  |       loading: true, | ||||||
|  |       loadingText: "Loading recipe...", | ||||||
|  |       tab: null, | ||||||
|  |       selectedRecipeField: "" as SelectedRecipeLeaves | "", | ||||||
|  |       canvasSelectedText: "", | ||||||
|  |       canvasImage: new Image(), | ||||||
|  |     }); | ||||||
|  |  | ||||||
|  |     const setPropertyValueByPath = function <T extends Recipe>(object: T, path: Paths<T>, value: any) { | ||||||
|  |       const a = path.split("."); | ||||||
|  |       let nextProperty: any = object; | ||||||
|  |       for (let i = 0, n = a.length - 1; i < n; ++i) { | ||||||
|  |         const k = a[i]; | ||||||
|  |         if (k in nextProperty) { | ||||||
|  |           nextProperty = nextProperty[k]; | ||||||
|  |         } else { | ||||||
|  |           return; | ||||||
|  |         } | ||||||
|  |       } | ||||||
|  |       nextProperty[a[a.length - 1]] = value; | ||||||
|  |     }; | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * This function will find the title of a recipe with the assumption that the title | ||||||
|  |      * has the biggest ratio of surface area / number of words on the image. | ||||||
|  |      * @return Returns the text parts of the block with the highest score. | ||||||
|  |      */ | ||||||
|  |     function findRecipeTitle() { | ||||||
|  |       const filtered = tsv.value.filter((element) => element.level === 2 || element.level === 5); | ||||||
|  |       const blocks = [[]] as OcrTsvResponse[][]; | ||||||
|  |       let blockNum = 1; | ||||||
|  |       filtered.forEach((element, index, array) => { | ||||||
|  |         if (index !== 0 && array[index - 1].blockNum !== element.blockNum) { | ||||||
|  |           blocks.push([]); | ||||||
|  |           blockNum = element.blockNum; | ||||||
|  |         } | ||||||
|  |         blocks[blockNum - 1].push(element); | ||||||
|  |       }); | ||||||
|  |  | ||||||
|  |       let bestScore = 0; | ||||||
|  |       let bestBlock = blocks[0]; | ||||||
|  |       blocks.forEach((element) => { | ||||||
|  |         // element[0] is the block declaration line containing the blocks total dimensions | ||||||
|  |         // element.length is the number of words (+ 2) contained in that block | ||||||
|  |         const elementScore = (element[0].height * element[0].width) / element.length; // Prettier is adding useless parenthesis for a mysterious reason | ||||||
|  |         const elementText = element.map((element) => element.text).join(""); // Identify empty blocks and don't count them | ||||||
|  |         if (elementScore > bestScore && elementText !== "") { | ||||||
|  |           bestBlock = element; | ||||||
|  |           bestScore = elementScore; | ||||||
|  |         } | ||||||
|  |       }); | ||||||
|  |  | ||||||
|  |       return bestBlock | ||||||
|  |         .filter((element) => element.level === 5 && element.conf >= 40) | ||||||
|  |         .map((element) => { | ||||||
|  |           return element.text.trim(); | ||||||
|  |         }) | ||||||
|  |         .join(" "); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     onMounted(() => { | ||||||
|  |       invoke(async () => { | ||||||
|  |         await until(props.recipe).not.toBeNull(); | ||||||
|  |         state.loadingText = "Loading OCR data..."; | ||||||
|  |  | ||||||
|  |         const assetName = props.recipe.assets[0].fileName; | ||||||
|  |         const imagesrc = assetURL(assetName); | ||||||
|  |         state.canvasImage.src = imagesrc; | ||||||
|  |  | ||||||
|  |         const res = await api.ocr.assetToTsv(props.recipe.slug, assetName); | ||||||
|  |         tsv.value = res.data as OcrTsvResponse[]; | ||||||
|  |         state.loading = false; | ||||||
|  |  | ||||||
|  |         if (props.recipe.name.match(/New\sOCR\sRecipe(\s\([0-9]+\))?/g)) { | ||||||
|  |           props.recipe.name = findRecipeTitle(); | ||||||
|  |         } | ||||||
|  |       }); | ||||||
|  |     }); | ||||||
|  |  | ||||||
|  |     function addIngredient(ingredients: Array<string> | null = null) { | ||||||
|  |       if (ingredients?.length) { | ||||||
|  |         const newIngredients = ingredients.map((x) => { | ||||||
|  |           return { | ||||||
|  |             referenceId: uuid4(), | ||||||
|  |             title: "", | ||||||
|  |             note: x, | ||||||
|  |             unit: undefined, | ||||||
|  |             food: undefined, | ||||||
|  |             disableAmount: true, | ||||||
|  |             quantity: 1, | ||||||
|  |             originalText: "", | ||||||
|  |           }; | ||||||
|  |         }); | ||||||
|  |  | ||||||
|  |         if (newIngredients) { | ||||||
|  |           // @ts-expect-error - prop can be null-type by NoUndefinedField type forces it to be set | ||||||
|  |           props.recipe.recipeIngredient.push(...newIngredients); | ||||||
|  |         } | ||||||
|  |       } else { | ||||||
|  |         props.recipe.recipeIngredient.push({ | ||||||
|  |           referenceId: uuid4(), | ||||||
|  |           title: "", | ||||||
|  |           note: "", | ||||||
|  |           // @ts-expect-error - prop can be null-type by NoUndefinedField type forces it to be set | ||||||
|  |           unit: undefined, | ||||||
|  |           // @ts-expect-error - prop can be null-type by NoUndefinedField type forces it to be set | ||||||
|  |           food: undefined, | ||||||
|  |           disableAmount: true, | ||||||
|  |           quantity: 1, | ||||||
|  |         }); | ||||||
|  |       } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     function addStep(steps: Array<string> | null = null) { | ||||||
|  |       if (!props.recipe.recipeInstructions) { | ||||||
|  |         return; | ||||||
|  |       } | ||||||
|  |  | ||||||
|  |       if (steps) { | ||||||
|  |         const cleanedSteps = steps.map((step) => { | ||||||
|  |           return { id: uuid4(), text: step, title: "", ingredientReferences: [] }; | ||||||
|  |         }); | ||||||
|  |  | ||||||
|  |         props.recipe.recipeInstructions.push(...cleanedSteps); | ||||||
|  |       } else { | ||||||
|  |         props.recipe.recipeInstructions.push({ id: uuid4(), text: "", title: "", ingredientReferences: [] }); | ||||||
|  |       } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     //  EVENT HANDLERS | ||||||
|  |  | ||||||
|  |     // Canvas component event handlers | ||||||
|  |     async function updateRecipe() { | ||||||
|  |       const { data } = await api.recipes.updateOne(props.recipe.slug, props.recipe); | ||||||
|  |       if (data?.slug) { | ||||||
|  |         router.push("/recipe/" + data.slug); | ||||||
|  |       } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     function closeEditor() { | ||||||
|  |       router.push("/recipe/" + props.recipe.slug); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     const canvasSetText = function () { | ||||||
|  |       if (state.selectedRecipeField !== "") { | ||||||
|  |         setPropertyValueByPath<Recipe>(props.recipe, state.selectedRecipeField, state.canvasSelectedText); | ||||||
|  |       } | ||||||
|  |     }; | ||||||
|  |  | ||||||
|  |     function updateSelectedText(value: string) { | ||||||
|  |       state.canvasSelectedText = value; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     // Recipe field selection event handlers | ||||||
|  |     function setSingleIngredient(f: keyof RecipeIngredient, index: number) { | ||||||
|  |       state.selectedRecipeField = `recipeIngredient.${index}.${f}` as SelectedRecipeLeaves; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     // Leaves<RecipeStep[]> will return some function types making eslint very unhappy | ||||||
|  |     type RecipeStepsLeaves = `${number}.${Leaves<RecipeStep>}`; | ||||||
|  |  | ||||||
|  |     function setSingleStep(path: RecipeStepsLeaves) { | ||||||
|  |       state.selectedRecipeField = `recipeInstructions.${path}` as SelectedRecipeLeaves; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     return { | ||||||
|  |       ...toRefs(state), | ||||||
|  |       addIngredient, | ||||||
|  |       addStep, | ||||||
|  |       drag, | ||||||
|  |       assetURL, | ||||||
|  |       updateRecipe, | ||||||
|  |       closeEditor, | ||||||
|  |       updateSelectedText, | ||||||
|  |       tsv, | ||||||
|  |       validators, | ||||||
|  |       setSingleIngredient, | ||||||
|  |       setSingleStep, | ||||||
|  |       canvasSetText, | ||||||
|  |     }; | ||||||
|  |   }, | ||||||
|  | }); | ||||||
|  | </script> | ||||||
|  |  | ||||||
|  | <style lang="css"> | ||||||
|  | .ghost { | ||||||
|  |   opacity: 0.5; | ||||||
|  | } | ||||||
|  | </style> | ||||||
| @@ -0,0 +1,484 @@ | |||||||
|  | <template> | ||||||
|  |   <v-card flat tile> | ||||||
|  |     <v-toolbar v-for="(section, idx) in toolbarIcons" :key="section.sectionTitle" dense style="float: left"> | ||||||
|  |       <v-toolbar-title bottom> | ||||||
|  |         {{ section.sectionTitle }} | ||||||
|  |       </v-toolbar-title> | ||||||
|  |       <v-tooltip v-for="icon in section.icons" :key="icon.name" bottom> | ||||||
|  |         <template #activator="{ on, attrs }"> | ||||||
|  |           <v-btn icon @click="section.eventHandler(icon.name)"> | ||||||
|  |             <v-icon :color="section.highlight === icon.name ? 'primary' : 'default'" v-bind="attrs" v-on="on"> | ||||||
|  |               {{ icon.icon }} | ||||||
|  |             </v-icon> | ||||||
|  |           </v-btn> | ||||||
|  |         </template> | ||||||
|  |         <span>{{ icon.tooltip }}</span> | ||||||
|  |       </v-tooltip> | ||||||
|  |       <v-divider v-if="idx != toolbarIcons.length - 1" vertical class="mx-2" /> | ||||||
|  |     </v-toolbar> | ||||||
|  |     <v-toolbar dense style="float: right"> | ||||||
|  |       <BaseButton class="ml-1 mr-1" save @click="updateRecipe()"> | ||||||
|  |         {{ $t("general.save") }} | ||||||
|  |       </BaseButton> | ||||||
|  |       <BaseButton cancel @click="closeEditor()"> | ||||||
|  |         {{ $t("general.close") }} | ||||||
|  |       </BaseButton> | ||||||
|  |     </v-toolbar> | ||||||
|  |     <canvas | ||||||
|  |       ref="canvas" | ||||||
|  |       @mousedown="handleMouseDown" | ||||||
|  |       @mouseup="handleMouseUp" | ||||||
|  |       @mousemove="handleMouseMove" | ||||||
|  |       @wheel="handleMouseScroll" | ||||||
|  |     > | ||||||
|  |     </canvas> | ||||||
|  |     <span style="white-space: pre-wrap"> | ||||||
|  |       {{ selectedText }} | ||||||
|  |     </span> | ||||||
|  |   </v-card> | ||||||
|  | </template> | ||||||
|  |  | ||||||
|  | <script lang="ts"> | ||||||
|  | import { defineComponent, reactive, useContext, ref, toRefs, watch } from "@nuxtjs/composition-api"; | ||||||
|  | import { onMounted } from "vue-demi"; | ||||||
|  | import { OcrTsvResponse } from "~/types/api-types/ocr"; | ||||||
|  | import { CanvasModes, SelectedTextSplitModes, ImagePosition, Mouse, CanvasRect, ToolbarIcons } from "~/types/ocr-types"; | ||||||
|  |  | ||||||
|  | export default defineComponent({ | ||||||
|  |   props: { | ||||||
|  |     image: { | ||||||
|  |       type: HTMLImageElement, | ||||||
|  |       required: true, | ||||||
|  |     }, | ||||||
|  |     tsv: { | ||||||
|  |       type: Array as () => OcrTsvResponse[], | ||||||
|  |       required: true, | ||||||
|  |     }, | ||||||
|  |   }, | ||||||
|  |   setup(props, context) { | ||||||
|  |     const state = reactive({ | ||||||
|  |       canvas: null as HTMLCanvasElement | null, | ||||||
|  |       ctx: null as CanvasRenderingContext2D | null, | ||||||
|  |       canvasRect: null as DOMRect | null, | ||||||
|  |       rect: { | ||||||
|  |         startX: 0, | ||||||
|  |         startY: 0, | ||||||
|  |         w: 0, | ||||||
|  |         h: 0, | ||||||
|  |       }, | ||||||
|  |       mouse: { | ||||||
|  |         current: { | ||||||
|  |           x: 0, | ||||||
|  |           y: 0, | ||||||
|  |         }, | ||||||
|  |         down: false, | ||||||
|  |       }, | ||||||
|  |       selectedText: "", | ||||||
|  |       canvasMode: "selection" as CanvasModes, | ||||||
|  |       imagePosition: { | ||||||
|  |         sx: 0, | ||||||
|  |         sy: 0, | ||||||
|  |         sWidth: 0, | ||||||
|  |         sHeight: 0, | ||||||
|  |         dx: 0, | ||||||
|  |         dy: 0, | ||||||
|  |         dWidth: 0, | ||||||
|  |         dHeight: 0, | ||||||
|  |         scale: 1, | ||||||
|  |         panStartPoint: { | ||||||
|  |           x: 0, | ||||||
|  |           y: 0, | ||||||
|  |         }, | ||||||
|  |       } as ImagePosition, | ||||||
|  |       isImageSmallerThanCanvas: false, | ||||||
|  |       selectedTextSplitMode: "lineNum" as SelectedTextSplitModes, | ||||||
|  |     }); | ||||||
|  |  | ||||||
|  |     watch( | ||||||
|  |       () => state.selectedText, | ||||||
|  |       (value) => { | ||||||
|  |         context.emit("text-selected", value); | ||||||
|  |       } | ||||||
|  |     ); | ||||||
|  |  | ||||||
|  |     onMounted(() => { | ||||||
|  |       if (state.canvas === null) return; // never happens because the ref "canvas" is in the template | ||||||
|  |       state.ctx = state.canvas.getContext("2d") as CanvasRenderingContext2D; | ||||||
|  |       state.ctx.imageSmoothingEnabled = false; | ||||||
|  |       state.canvasRect = state.canvas.getBoundingClientRect(); | ||||||
|  |  | ||||||
|  |       state.canvas.width = state.canvasRect.width; | ||||||
|  |       if (props.image.width < state.canvas.width) { | ||||||
|  |         state.isImageSmallerThanCanvas = true; | ||||||
|  |       } | ||||||
|  |       state.imagePosition.dWidth = state.canvas.width; | ||||||
|  |  | ||||||
|  |       updateImageScale(); | ||||||
|  |       state.canvas.height = Math.min(props.image.height * state.imagePosition.scale, 700); // Max height of 700px | ||||||
|  |  | ||||||
|  |       state.imagePosition.sWidth = props.image.width; | ||||||
|  |       state.imagePosition.sHeight = props.image.height; | ||||||
|  |       state.imagePosition.dWidth = state.canvas.width; | ||||||
|  |       drawImage(state.ctx); | ||||||
|  |       drawWordBoxesOnCanvas(props.tsv); | ||||||
|  |     }); | ||||||
|  |  | ||||||
|  |     function handleMouseDown(event: MouseEvent) { | ||||||
|  |       if (state.canvasRect === null || state.canvas === null || state.ctx === null) return; | ||||||
|  |       state.mouse.down = true; | ||||||
|  |  | ||||||
|  |       updateMousePos(event); | ||||||
|  |  | ||||||
|  |       if (state.canvasMode === "selection") { | ||||||
|  |         if (isMouseInRect(state.mouse, state.rect)) { | ||||||
|  |           context.emit("setText", state.selectedText); | ||||||
|  |         } else { | ||||||
|  |           state.ctx.fillStyle = "rgb(255, 255, 255)"; | ||||||
|  |           state.ctx.fillRect(0, 0, state.canvas.width, state.canvas.height); | ||||||
|  |           drawImage(state.ctx); | ||||||
|  |           state.rect.startX = state.mouse.current.x; | ||||||
|  |           state.rect.startY = state.mouse.current.y; | ||||||
|  |           resetSelection(); | ||||||
|  |         } | ||||||
|  |         return; | ||||||
|  |       } | ||||||
|  |  | ||||||
|  |       if (state.canvasMode === "panAndZoom") { | ||||||
|  |         state.imagePosition.panStartPoint.x = state.mouse.current.x - state.imagePosition.dx; | ||||||
|  |         state.imagePosition.panStartPoint.y = state.mouse.current.y - state.imagePosition.dy; | ||||||
|  |         resetSelection(); | ||||||
|  |       } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     function handleMouseUp(_event: MouseEvent) { | ||||||
|  |       if (state.canvasRect === null) return; | ||||||
|  |       state.mouse.down = false; | ||||||
|  |       state.selectedText = getWordsInSelection(props.tsv, state.rect); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     function handleMouseMove(event: MouseEvent) { | ||||||
|  |       if (state.canvasRect === null || state.canvas === null || state.ctx === null) return; | ||||||
|  |  | ||||||
|  |       updateMousePos(event); | ||||||
|  |  | ||||||
|  |       if (state.mouse.down) { | ||||||
|  |         if (state.canvasMode === "selection") { | ||||||
|  |           state.rect.w = state.mouse.current.x - state.rect.startX; | ||||||
|  |           state.rect.h = state.mouse.current.y - state.rect.startY; | ||||||
|  |           draw(); | ||||||
|  |           return; | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         if (state.canvasMode === "panAndZoom") { | ||||||
|  |           state.canvas.style.cursor = "move"; | ||||||
|  |           state.imagePosition.dx = state.mouse.current.x - state.imagePosition.panStartPoint.x; | ||||||
|  |           state.imagePosition.dy = state.mouse.current.y - state.imagePosition.panStartPoint.y; | ||||||
|  |           keepImageInCanvas(); | ||||||
|  |           state.ctx.fillStyle = "rgb(255, 255, 255)"; | ||||||
|  |           state.ctx.fillRect(0, 0, state.canvas.width, state.canvas.height); | ||||||
|  |           drawImage(state.ctx); | ||||||
|  |           return; | ||||||
|  |         } | ||||||
|  |       } | ||||||
|  |  | ||||||
|  |       if (isMouseInRect(state.mouse, state.rect) && state.canvasMode === "selection") { | ||||||
|  |         state.canvas.style.cursor = "pointer"; | ||||||
|  |       } else { | ||||||
|  |         state.canvas.style.cursor = "default"; | ||||||
|  |       } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     const scrollSensitivity = 0.05; | ||||||
|  |  | ||||||
|  |     function handleMouseScroll(event: WheelEvent) { | ||||||
|  |       if (state.isImageSmallerThanCanvas) return; | ||||||
|  |       if (state.canvasRect === null || state.canvas === null || state.ctx === null) return; | ||||||
|  |  | ||||||
|  |       if (state.canvasMode === "panAndZoom") { | ||||||
|  |         event.preventDefault(); | ||||||
|  |  | ||||||
|  |         updateMousePos(event); | ||||||
|  |  | ||||||
|  |         const m = Math.sign(event.deltaY); | ||||||
|  |  | ||||||
|  |         const ndx = state.imagePosition.dx + m * state.imagePosition.dWidth * scrollSensitivity; | ||||||
|  |         const ndy = state.imagePosition.dy + m * state.imagePosition.dHeight * scrollSensitivity; | ||||||
|  |         const ndw = state.imagePosition.dWidth + -m * state.imagePosition.dWidth * scrollSensitivity * 2; | ||||||
|  |         const ndh = state.imagePosition.dHeight + -m * state.imagePosition.dHeight * scrollSensitivity * 2; | ||||||
|  |  | ||||||
|  |         if (ndw < props.image.width) { | ||||||
|  |           state.imagePosition.dx = ndx; | ||||||
|  |           state.imagePosition.dy = ndy; | ||||||
|  |           state.imagePosition.dWidth = ndw; | ||||||
|  |           state.imagePosition.dHeight = ndh; | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         keepImageInCanvas(); | ||||||
|  |         updateImageScale(); | ||||||
|  |  | ||||||
|  |         state.ctx.fillStyle = "rgb(255, 255, 255)"; | ||||||
|  |         state.ctx.fillRect(0, 0, state.canvas.width, state.canvas.height); | ||||||
|  |         drawImage(state.ctx); | ||||||
|  |       } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     function draw() { | ||||||
|  |       if (state.canvasRect === null || state.canvas === null || state.ctx === null) return; | ||||||
|  |       if (state.mouse.down) { | ||||||
|  |         state.ctx.imageSmoothingEnabled = false; | ||||||
|  |         state.ctx.fillStyle = "rgb(255, 255, 255)"; | ||||||
|  |         state.ctx.fillRect(0, 0, state.canvas.width, state.canvas.height); | ||||||
|  |         drawImage(state.ctx); | ||||||
|  |         state.ctx.fillStyle = "rgba(255, 255, 255, 0.1)"; | ||||||
|  |         state.ctx.setLineDash([6]); | ||||||
|  |         state.ctx.fillRect(state.rect.startX, state.rect.startY, state.rect.w, state.rect.h); | ||||||
|  |         state.ctx.strokeRect(state.rect.startX, state.rect.startY, state.rect.w, state.rect.h); | ||||||
|  |       } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     function drawImage(ctx: CanvasRenderingContext2D) { | ||||||
|  |       ctx.drawImage( | ||||||
|  |         props.image, | ||||||
|  |         state.imagePosition.sx, | ||||||
|  |         state.imagePosition.sy, | ||||||
|  |         state.imagePosition.sWidth, | ||||||
|  |         state.imagePosition.sHeight, | ||||||
|  |         state.imagePosition.dx, | ||||||
|  |         state.imagePosition.dy, | ||||||
|  |         state.imagePosition.dWidth, | ||||||
|  |         state.imagePosition.dHeight | ||||||
|  |       ); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     function keepImageInCanvas() { | ||||||
|  |       if (state.canvasRect === null || state.canvas === null) return; | ||||||
|  |  | ||||||
|  |       // Prevent image from being smaller than the canvas width | ||||||
|  |       if (state.imagePosition.dWidth - state.canvas.width < 0) { | ||||||
|  |         state.imagePosition.dWidth = state.canvas.width; | ||||||
|  |       } | ||||||
|  |  | ||||||
|  |       // Prevent image from being smaller than the canvas height | ||||||
|  |       if (state.imagePosition.dHeight - state.canvas.height < 0) { | ||||||
|  |         state.imagePosition.dHeight = props.image.height * state.imagePosition.scale; | ||||||
|  |       } | ||||||
|  |  | ||||||
|  |       // Prevent to move the image too much to the left | ||||||
|  |       if (state.canvas.width - state.imagePosition.dx - state.imagePosition.dWidth > 0) { | ||||||
|  |         state.imagePosition.dx = state.canvas.width - state.imagePosition.dWidth; | ||||||
|  |       } | ||||||
|  |  | ||||||
|  |       // Prevent to move the image too much to the top | ||||||
|  |       if (state.canvas.height - state.imagePosition.dy - state.imagePosition.dHeight > 0) { | ||||||
|  |         state.imagePosition.dy = state.canvas.height - state.imagePosition.dHeight; | ||||||
|  |       } | ||||||
|  |  | ||||||
|  |       // Prevent to move the image too much to the right | ||||||
|  |       if (state.imagePosition.dx > 0) { | ||||||
|  |         state.imagePosition.dx = 0; | ||||||
|  |       } | ||||||
|  |  | ||||||
|  |       // Prevent to move the image too much to the bottom | ||||||
|  |       if (state.imagePosition.dy > 0) { | ||||||
|  |         state.imagePosition.dy = 0; | ||||||
|  |       } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     function updateImageScale() { | ||||||
|  |       state.imagePosition.scale = state.imagePosition.dWidth / props.image.width; | ||||||
|  |  | ||||||
|  |       // force the original ratio to be respected | ||||||
|  |       state.imagePosition.dHeight = props.image.height * state.imagePosition.scale; | ||||||
|  |  | ||||||
|  |       // Don't let images bigger than the canvas be zoomed in more than 1:1 scale | ||||||
|  |       // Meaning only let images smaller than the canvas to have a scale > 1 | ||||||
|  |       if (!state.isImageSmallerThanCanvas && state.imagePosition.scale > 1) { | ||||||
|  |         state.imagePosition.scale = 1; | ||||||
|  |       } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     function resetSelection() { | ||||||
|  |       if (state.canvasRect === null) return; | ||||||
|  |       state.rect.w = 0; | ||||||
|  |       state.rect.h = 0; | ||||||
|  |       state.selectedText = ""; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     function updateMousePos<T extends MouseEvent>(event: T) { | ||||||
|  |       if (state.canvas === null) return; | ||||||
|  |       state.canvasRect = state.canvas.getBoundingClientRect(); | ||||||
|  |       state.mouse.current = { | ||||||
|  |         x: event.clientX - state.canvasRect.left, | ||||||
|  |         y: event.clientY - state.canvasRect.top, | ||||||
|  |       }; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     function isMouseInRect(mouse: Mouse, rect: CanvasRect) { | ||||||
|  |       if (state.canvasRect === null) return; | ||||||
|  |       const correctRect = correctRectCoordinates(rect); | ||||||
|  |  | ||||||
|  |       return ( | ||||||
|  |         mouse.current.x > correctRect.startX && | ||||||
|  |         mouse.current.x < correctRect.startX + correctRect.w && | ||||||
|  |         mouse.current.y > correctRect.startY && | ||||||
|  |         mouse.current.y < correctRect.startY + correctRect.h | ||||||
|  |       ); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * Returns rectangle coordinates with positive dimensions | ||||||
|  |      * @param  rect  A rectangle | ||||||
|  |      * @returns  An equivalent rectangle with width and height > 0 | ||||||
|  |      */ | ||||||
|  |     function correctRectCoordinates(rect: CanvasRect) { | ||||||
|  |       if (rect.w < 0) { | ||||||
|  |         rect.startX = rect.startX + rect.w; | ||||||
|  |         rect.w = -rect.w; | ||||||
|  |       } | ||||||
|  |  | ||||||
|  |       if (rect.h < 0) { | ||||||
|  |         rect.startY = rect.startY + rect.h; | ||||||
|  |         rect.h = -rect.h; | ||||||
|  |       } | ||||||
|  |       return rect; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     function drawWordBoxesOnCanvas(tsv: OcrTsvResponse[]) { | ||||||
|  |       if (state.canvasRect === null || state.canvas === null || state.ctx === null) return; | ||||||
|  |  | ||||||
|  |       state.ctx.fillStyle = "rgb(255, 255, 255, 0.3)"; | ||||||
|  |       tsv | ||||||
|  |         .filter((element) => element.level === 5) | ||||||
|  |         .forEach((element) => { | ||||||
|  |           if (state.canvasRect === null || state.canvas === null || state.ctx === null) return; | ||||||
|  |           state.ctx.fillRect( | ||||||
|  |             element.left * state.imagePosition.scale, | ||||||
|  |             element.top * state.imagePosition.scale, | ||||||
|  |             element.width * state.imagePosition.scale, | ||||||
|  |             element.height * state.imagePosition.scale | ||||||
|  |           ); | ||||||
|  |         }); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     // Event emitters | ||||||
|  |     const updateRecipe = function () { | ||||||
|  |       context.emit("update-recipe"); | ||||||
|  |     }; | ||||||
|  |  | ||||||
|  |     const closeEditor = function () { | ||||||
|  |       context.emit("close-editor"); | ||||||
|  |     }; | ||||||
|  |  | ||||||
|  |     // TOOLBAR STUFF | ||||||
|  |  | ||||||
|  |     const { $globals, i18n } = useContext(); | ||||||
|  |  | ||||||
|  |     const toolbarIcons = ref<ToolbarIcons<CanvasModes | SelectedTextSplitModes>>([ | ||||||
|  |       { | ||||||
|  |         sectionTitle: "Toolbar", | ||||||
|  |         eventHandler: switchCanvasMode, | ||||||
|  |         highlight: state.canvasMode, | ||||||
|  |         icons: [ | ||||||
|  |           { | ||||||
|  |             name: "selection", | ||||||
|  |             icon: $globals.icons.selectMode, | ||||||
|  |             tooltip: i18n.tc("ocr-editor.selection-mode"), | ||||||
|  |           }, | ||||||
|  |           { | ||||||
|  |             name: "panAndZoom", | ||||||
|  |             icon: $globals.icons.panAndZoom, | ||||||
|  |             tooltip: i18n.tc("ocr-editor.pan-and-zoom-picture"), | ||||||
|  |           }, | ||||||
|  |         ], | ||||||
|  |       }, | ||||||
|  |       { | ||||||
|  |         sectionTitle: i18n.tc("ocr-editor.split-text"), | ||||||
|  |         eventHandler: switchSplitTextMode, | ||||||
|  |         highlight: state.selectedTextSplitMode, | ||||||
|  |         icons: [ | ||||||
|  |           { | ||||||
|  |             name: "lineNum", | ||||||
|  |             icon: $globals.icons.preserveLines, | ||||||
|  |             tooltip: i18n.tc("ocr-editor.preserve-line-breaks"), | ||||||
|  |           }, | ||||||
|  |           { | ||||||
|  |             name: "blockNum", | ||||||
|  |             icon: $globals.icons.preserveBlocks, | ||||||
|  |             tooltip: i18n.tc("ocr-editor.split-by-block"), | ||||||
|  |           }, | ||||||
|  |           { | ||||||
|  |             name: "flatten", | ||||||
|  |             icon: $globals.icons.flatten, | ||||||
|  |             tooltip: i18n.tc("ocr-editor.flatten"), | ||||||
|  |           }, | ||||||
|  |         ], | ||||||
|  |       }, | ||||||
|  |     ]); | ||||||
|  |  | ||||||
|  |     function switchCanvasMode(mode: CanvasModes) { | ||||||
|  |       if (state.canvasRect === null || state.canvas === null) return; | ||||||
|  |       state.canvasMode = mode; | ||||||
|  |       toolbarIcons.value[0].highlight = mode; | ||||||
|  |       if (mode === "panAndZoom") { | ||||||
|  |         state.canvas.style.cursor = "pointer"; | ||||||
|  |       } else { | ||||||
|  |         state.canvas.style.cursor = "default"; | ||||||
|  |       } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     function switchSplitTextMode(mode: SelectedTextSplitModes) { | ||||||
|  |       if (state.canvasRect === null) return; | ||||||
|  |       state.selectedTextSplitMode = mode; | ||||||
|  |       toolbarIcons.value[1].highlight = mode; | ||||||
|  |       state.selectedText = getWordsInSelection(props.tsv, state.rect); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * Using rectangle coordinates, filters the tsv to get text elements contained | ||||||
|  |      * inside the rectangle | ||||||
|  |      * Additionaly adds newlines depending on the current "text split" mode | ||||||
|  |      * @param  tsv   An Object containing tesseracts tsv fields | ||||||
|  |      * @param  rect  Coordinates of a rectangle | ||||||
|  |      * @returns Text from tsv contained in the rectangle | ||||||
|  |      */ | ||||||
|  |     function getWordsInSelection(tsv: OcrTsvResponse[], rect: CanvasRect) { | ||||||
|  |       const correctedRect = correctRectCoordinates(rect); | ||||||
|  |  | ||||||
|  |       return tsv | ||||||
|  |         .filter( | ||||||
|  |           (element) => | ||||||
|  |             element.level === 5 && | ||||||
|  |             correctedRect.startY - state.imagePosition.dy < element.top * state.imagePosition.scale && | ||||||
|  |             correctedRect.startX - state.imagePosition.dx < element.left * state.imagePosition.scale && | ||||||
|  |             correctedRect.startX + correctedRect.w > | ||||||
|  |               (element.left + element.width) * state.imagePosition.scale + state.imagePosition.dx && | ||||||
|  |             correctedRect.startY + correctedRect.h > | ||||||
|  |               (element.top + element.height) * state.imagePosition.scale + state.imagePosition.dy | ||||||
|  |         ) | ||||||
|  |         .map((element, index, array) => { | ||||||
|  |           let separator = " "; | ||||||
|  |           if ( | ||||||
|  |             state.selectedTextSplitMode !== "flatten" && | ||||||
|  |             index !== array.length - 1 && | ||||||
|  |             element[state.selectedTextSplitMode] !== array[index + 1][state.selectedTextSplitMode] | ||||||
|  |           ) { | ||||||
|  |             separator = "\n"; | ||||||
|  |           } | ||||||
|  |           return element.text + separator; | ||||||
|  |         }) | ||||||
|  |         .join(""); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     return { | ||||||
|  |       ...toRefs(state), | ||||||
|  |       handleMouseDown, | ||||||
|  |       handleMouseUp, | ||||||
|  |       handleMouseMove, | ||||||
|  |       handleMouseScroll, | ||||||
|  |       toolbarIcons, | ||||||
|  |       updateRecipe, | ||||||
|  |       closeEditor, | ||||||
|  |     }; | ||||||
|  |   }, | ||||||
|  | }); | ||||||
|  | </script> | ||||||
| @@ -0,0 +1,54 @@ | |||||||
|  | <template> | ||||||
|  |   <v-card> | ||||||
|  |     <v-app-bar dense dark color="primary" class="mb-2"> | ||||||
|  |       <v-icon large left> | ||||||
|  |         {{ $globals.icons.help }} | ||||||
|  |       </v-icon> | ||||||
|  |       <v-toolbar-title class="headline"> Help </v-toolbar-title> | ||||||
|  |       <v-spacer></v-spacer> | ||||||
|  |     </v-app-bar> | ||||||
|  |     <v-card-text> | ||||||
|  |       <h1>Mouse modes</h1> | ||||||
|  |       <v-divider class="mb-2 mt-1" /> | ||||||
|  |       <h2 class="my-2"> | ||||||
|  |         <v-icon> {{ $globals.icons.selectMode }} </v-icon>{{ $t("ocr-editor.help.selection-mode") }} | ||||||
|  |       </h2> | ||||||
|  |       <p class="my-1">{{ $t("ocr-editor.help.selection-mode") }}</p> | ||||||
|  |       <ol> | ||||||
|  |         <li>{{ $t("ocr-editor.help.selection-mode-steps.draw") }}</li> | ||||||
|  |         <li>{{ $t("ocr-editor.help.selection-mode-steps.click") }}</li> | ||||||
|  |         <li>{{ $t("ocr-editor.help.selection-mode-steps.result") }}</li> | ||||||
|  |       </ol> | ||||||
|  |       <h2 class="my-2"> | ||||||
|  |         <v-icon> {{ $globals.icons.panAndZoom }} </v-icon>{{ $t("ocr-editor.help.pan-and-zoom-mode") }} | ||||||
|  |       </h2> | ||||||
|  |       {{ $t("ocr-editor.help.pan-and-zoom-desc") }} | ||||||
|  |       <h1 class="mt-5">{{ $t("ocr-editor.help.split-text-mode") }}</h1> | ||||||
|  |       <v-divider class="mb-2 mt-1" /> | ||||||
|  |       <h2 class="my-2"> | ||||||
|  |         <v-icon> {{ $globals.icons.preserveLines }} </v-icon> | ||||||
|  |         {{ $t("ocr-editor.help.split-modes.line-mode") }} | ||||||
|  |       </h2> | ||||||
|  |       <p> | ||||||
|  |         {{ $t("ocr-editor.help.split-modes.line-mode-desc") }} | ||||||
|  |       </p> | ||||||
|  |       <h2 class="my-2"> | ||||||
|  |         <v-icon> {{ $globals.icons.preserveBlocks }} </v-icon> | ||||||
|  |         {{ $t("ocr-editor.help.split-modes.block-mode") }} | ||||||
|  |       </h2> | ||||||
|  |       <p> | ||||||
|  |         {{ $t("ocr-editor.help.split-modes.block-mode-desc") }} | ||||||
|  |       </p> | ||||||
|  |       <h2 class="my-2"> | ||||||
|  |         <v-icon> {{ $globals.icons.flatten }} </v-icon> {{ $t("ocr-editor.help.split-modes.flat-mode") }} | ||||||
|  |       </h2> | ||||||
|  |       <p>{{ $t("ocr-editor.help.split-modes.flat-mode-desc") }}</p> | ||||||
|  |     </v-card-text> | ||||||
|  |   </v-card> | ||||||
|  | </template> | ||||||
|  |  | ||||||
|  | <script lang="ts"> | ||||||
|  | import { defineComponent } from "@nuxtjs/composition-api"; | ||||||
|  |  | ||||||
|  | export default defineComponent({}); | ||||||
|  | </script> | ||||||
| @@ -0,0 +1,3 @@ | |||||||
|  | import RecipeOcrEditorPage from "./RecipeOcrEditorPage.vue"; | ||||||
|  |  | ||||||
|  | export default RecipeOcrEditorPage; | ||||||
| @@ -42,6 +42,7 @@ | |||||||
|       :logged-in="$auth.loggedIn" |       :logged-in="$auth.loggedIn" | ||||||
|       :open="isEditMode" |       :open="isEditMode" | ||||||
|       :recipe-id="recipe.id" |       :recipe-id="recipe.id" | ||||||
|  |       :show-ocr-button="recipe.isOcrRecipe" | ||||||
|       class="ml-auto mt-n8 pb-4" |       class="ml-auto mt-n8 pb-4" | ||||||
|       @close="setMode(PageMode.VIEW)" |       @close="setMode(PageMode.VIEW)" | ||||||
|       @json="toggleEditMode()" |       @json="toggleEditMode()" | ||||||
| @@ -49,12 +50,13 @@ | |||||||
|       @save="$emit('save')" |       @save="$emit('save')" | ||||||
|       @delete="$emit('delete')" |       @delete="$emit('delete')" | ||||||
|       @print="printRecipe" |       @print="printRecipe" | ||||||
|  |       @ocr="goToOcrEditor" | ||||||
|     /> |     /> | ||||||
|   </div> |   </div> | ||||||
| </template> | </template> | ||||||
|  |  | ||||||
| <script lang="ts"> | <script lang="ts"> | ||||||
| import { defineComponent, useContext, computed, ref, watch } from "@nuxtjs/composition-api"; | import { defineComponent, useContext, computed, ref, watch, useRouter } from "@nuxtjs/composition-api"; | ||||||
| import RecipeRating from "~/components/Domain/Recipe/RecipeRating.vue"; | import RecipeRating from "~/components/Domain/Recipe/RecipeRating.vue"; | ||||||
| import RecipeActionMenu from "~/components/Domain/Recipe/RecipeActionMenu.vue"; | import RecipeActionMenu from "~/components/Domain/Recipe/RecipeActionMenu.vue"; | ||||||
| import RecipeTimeCard from "~/components/Domain/Recipe/RecipeTimeCard.vue"; | import RecipeTimeCard from "~/components/Domain/Recipe/RecipeTimeCard.vue"; | ||||||
| @@ -82,6 +84,7 @@ export default defineComponent({ | |||||||
|     const { recipeImage } = useStaticRoutes(); |     const { recipeImage } = useStaticRoutes(); | ||||||
|     const { imageKey, pageMode, editMode, setMode, toggleEditMode, isEditMode } = usePageState(props.recipe.slug); |     const { imageKey, pageMode, editMode, setMode, toggleEditMode, isEditMode } = usePageState(props.recipe.slug); | ||||||
|     const { user } = usePageUser(); |     const { user } = usePageUser(); | ||||||
|  |     const router = useRouter(); | ||||||
|  |  | ||||||
|     function printRecipe() { |     function printRecipe() { | ||||||
|       window.print(); |       window.print(); | ||||||
| @@ -98,6 +101,10 @@ export default defineComponent({ | |||||||
|       return recipeImage(props.recipe.id, props.recipe.image, imageKey.value); |       return recipeImage(props.recipe.id, props.recipe.image, imageKey.value); | ||||||
|     }); |     }); | ||||||
|  |  | ||||||
|  |     function goToOcrEditor() { | ||||||
|  |       router.push("/recipe/" + props.recipe.slug + "/ocr-editor"); | ||||||
|  |     } | ||||||
|  |  | ||||||
|     watch( |     watch( | ||||||
|       () => recipeImageUrl.value, |       () => recipeImageUrl.value, | ||||||
|       () => { |       () => { | ||||||
| @@ -120,6 +127,7 @@ export default defineComponent({ | |||||||
|       hideImage, |       hideImage, | ||||||
|       isEditMode, |       isEditMode, | ||||||
|       recipeImageUrl, |       recipeImageUrl, | ||||||
|  |       goToOcrEditor, | ||||||
|     }; |     }; | ||||||
|   }, |   }, | ||||||
| }); | }); | ||||||
|   | |||||||
| @@ -248,7 +248,8 @@ | |||||||
|     "trim-prefix-description": "Trim first character from each line", |     "trim-prefix-description": "Trim first character from each line", | ||||||
|     "split-by-numbered-line-description": "Attempts to split a paragraph by matching '1)' or '1.' patterns", |     "split-by-numbered-line-description": "Attempts to split a paragraph by matching '1)' or '1.' patterns", | ||||||
|     "import-by-url": "Import a recipe by URL", |     "import-by-url": "Import a recipe by URL", | ||||||
|     "create-manually": "Create a recipe manually" |     "create-manually": "Create a recipe manually", | ||||||
|  |     "make-recipe-image": "Make this the recipe image" | ||||||
|   }, |   }, | ||||||
|   "page": { |   "page": { | ||||||
|     "404-page-not-found": "404 Page not found", |     "404-page-not-found": "404 Page not found", | ||||||
| @@ -660,5 +661,34 @@ | |||||||
|     "info_message_with_version": "This is a Demo for version: {version}", |     "info_message_with_version": "This is a Demo for version: {version}", | ||||||
|     "demo_username": "Username: {username}", |     "demo_username": "Username: {username}", | ||||||
|     "demo_password": "Password: {password}" |     "demo_password": "Password: {password}" | ||||||
|  |   }, | ||||||
|  |   "ocr-editor": { | ||||||
|  |     "ocr-editor": "Ocr editor", | ||||||
|  |     "selection-mode": "Selection mode", | ||||||
|  |     "pan-and-zoom-picture": "Pan and zoom picture", | ||||||
|  |     "split-text": "Split text", | ||||||
|  |     "preserve-line-breaks": "Preserve original line breaks", | ||||||
|  |     "split-by-block": "Split by text block", | ||||||
|  |     "flatten": "Flatten regardless of original formating", | ||||||
|  |     "help": { | ||||||
|  |       "selection-mode": "Selection Mode (default)", | ||||||
|  |       "selection-mode-desc": "The selection mode is the main mode that can be used to enter data:", | ||||||
|  |       "selection-mode-steps": { | ||||||
|  |         "draw": "Draw a rectangle on the text you want to select.", | ||||||
|  |         "click": "Click on any field on the right and then click back on the rectangle above the image.", | ||||||
|  |         "result": "The selected text will appear inside the previously selected field." | ||||||
|  |       }, | ||||||
|  |       "pan-and-zoom-mode": "Pan and Zoom Mode", | ||||||
|  |       "pan-and-zoom-desc": "Select pan and zoom by clicking the icon. This mode allows to zoom inside the image and move around to make using big images easier.", | ||||||
|  |       "split-text-mode": "Split Text modes", | ||||||
|  |       "split-modes": { | ||||||
|  |         "line-mode": "Line mode (default)", | ||||||
|  |         "line-mode-desc": "In line mode, the text will be propagated by keeping the original line breaks. This mode is useful when using bulk add on a list of ingredients where one ingredient is one line.", | ||||||
|  |         "block-mode": "Block mode", | ||||||
|  |         "block-mode-desc": "In block mode, the text will be split in blocks. This mode is useful when bulk adding instructions that are usually written in paragraphs.", | ||||||
|  |         "flat-mode": "Flat mode", | ||||||
|  |         "flat-mode-desc": "In flat mode, the text will be added to the selected recipe field with no line breaks." | ||||||
|  |       } | ||||||
|  |     } | ||||||
|   } |   } | ||||||
| } | } | ||||||
|   | |||||||
| @@ -60,7 +60,7 @@ | |||||||
|                 {{ isError(ing) ? $globals.icons.alert : $globals.icons.check }} |                 {{ isError(ing) ? $globals.icons.alert : $globals.icons.check }} | ||||||
|               </v-icon> |               </v-icon> | ||||||
|               <div class="my-auto" :color="isError(ing) ? 'error-text' : 'success-text'"> |               <div class="my-auto" :color="isError(ing) ? 'error-text' : 'success-text'"> | ||||||
|                 {{ asPercentage(ing.confidence.average) }} |                 {{ ing.confidence ? asPercentage(ing.confidence.average) : "" }} | ||||||
|               </div> |               </div> | ||||||
|             </template> |             </template> | ||||||
|           </v-expansion-panel-header> |           </v-expansion-panel-header> | ||||||
| @@ -197,7 +197,11 @@ export default defineComponent({ | |||||||
|       return !(ing.confidence.average >= 0.75); |       return !(ing.confidence.average >= 0.75); | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     function asPercentage(num: number) { |     function asPercentage(num: number | undefined): string { | ||||||
|  |       if (!num) { | ||||||
|  |         return "0%"; | ||||||
|  |       } | ||||||
|  |  | ||||||
|       return Math.round(num * 100).toFixed(2) + "%"; |       return Math.round(num * 100).toFixed(2) + "%"; | ||||||
|     } |     } | ||||||
|  |  | ||||||
| @@ -230,7 +234,11 @@ export default defineComponent({ | |||||||
|       return false; |       return false; | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     async function createFood(food: CreateIngredientFood, index: number) { |     async function createFood(food: CreateIngredientFood | undefined, index: number) { | ||||||
|  |       if (!food) { | ||||||
|  |         return; | ||||||
|  |       } | ||||||
|  |  | ||||||
|       foodData.data.name = food.name; |       foodData.data.name = food.name; | ||||||
|       await foodStore.actions.createOne(foodData.data); |       await foodStore.actions.createOne(foodData.data); | ||||||
|       errors.value[index].foodError = false; |       errors.value[index].foodError = false; | ||||||
|   | |||||||
							
								
								
									
										51
									
								
								frontend/pages/recipe/_slug/ocr-editor.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										51
									
								
								frontend/pages/recipe/_slug/ocr-editor.vue
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,51 @@ | |||||||
|  | <template> | ||||||
|  |   <div> | ||||||
|  |     <RecipeOcrEditorPage v-if="recipe" :recipe="recipe" /> | ||||||
|  |   </div> | ||||||
|  | </template> | ||||||
|  |  | ||||||
|  | <script lang="ts"> | ||||||
|  | import { defineComponent, useRoute } from "@nuxtjs/composition-api"; | ||||||
|  | import RecipeOcrEditorPage from "~/components/Domain/Recipe/RecipeOcrEditorPage/RecipeOcrEditorPage.vue"; | ||||||
|  | import { useRecipe } from "~/composables/recipes"; | ||||||
|  |  | ||||||
|  | export default defineComponent({ | ||||||
|  |   components: { RecipeOcrEditorPage }, | ||||||
|  |   setup() { | ||||||
|  |     const route = useRoute(); | ||||||
|  |     const slug = route.value.params.slug; | ||||||
|  |  | ||||||
|  |     const { recipe, loading } = useRecipe(slug); | ||||||
|  |  | ||||||
|  |     return { | ||||||
|  |       recipe, | ||||||
|  |       loading, | ||||||
|  |     }; | ||||||
|  |   }, | ||||||
|  | }); | ||||||
|  | </script> | ||||||
|  |  | ||||||
|  | <style lang="css"> | ||||||
|  | .ghost { | ||||||
|  |   opacity: 0.5; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | body { | ||||||
|  |   background: #eee; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | canvas { | ||||||
|  |   background: white; | ||||||
|  |   box-shadow: 0px 2px 3px rgba(0, 0, 0, 0.2); | ||||||
|  |   width: 100%; | ||||||
|  |   image-rendering: optimizeQuality; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .box { | ||||||
|  |   position: absolute; | ||||||
|  |   border: 2px #90ee90 solid; | ||||||
|  |   background-color: #90ee90; | ||||||
|  |  | ||||||
|  |   z-index: 3; | ||||||
|  | } | ||||||
|  | </style> | ||||||
| @@ -52,6 +52,11 @@ export default defineComponent({ | |||||||
|         text: "Import with .zip", |         text: "Import with .zip", | ||||||
|         value: "zip", |         value: "zip", | ||||||
|       }, |       }, | ||||||
|  |       { | ||||||
|  |         icon: $globals.icons.fileImage, | ||||||
|  |         text: "Create recipe from an image", | ||||||
|  |         value: "ocr", | ||||||
|  |       }, | ||||||
|       { |       { | ||||||
|         icon: $globals.icons.link, |         icon: $globals.icons.link, | ||||||
|         text: "Bulk URL Import", |         text: "Bulk URL Import", | ||||||
|   | |||||||
							
								
								
									
										81
									
								
								frontend/pages/recipe/create/ocr.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										81
									
								
								frontend/pages/recipe/create/ocr.vue
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,81 @@ | |||||||
|  | <template> | ||||||
|  |   <div> | ||||||
|  |     <v-card-title class="headline"> Create Recipe from an Image </v-card-title> | ||||||
|  |     <v-card-text> | ||||||
|  |       Create a recipe by uploading a scan. | ||||||
|  |       <v-form ref="domCreateByOcr"> </v-form> | ||||||
|  |     </v-card-text> | ||||||
|  |     <v-card-actions class="justify-center"> | ||||||
|  |       <v-file-input | ||||||
|  |         v-model="imageUpload" | ||||||
|  |         accept=".png" | ||||||
|  |         label="recipe.png" | ||||||
|  |         filled | ||||||
|  |         clearable | ||||||
|  |         class="rounded-lg mt-2" | ||||||
|  |         rounded | ||||||
|  |         truncate-length="100" | ||||||
|  |         hint="Upload a png image from a recipe book" | ||||||
|  |         persistent-hint | ||||||
|  |         prepend-icon="" | ||||||
|  |         :prepend-inner-icon="$globals.icons.fileImage" | ||||||
|  |       /> | ||||||
|  |     </v-card-actions> | ||||||
|  |     <v-card-actions class="justify-center"> | ||||||
|  |       <v-checkbox v-model="makeFileRecipeImage" :label="$t('new-recipe.make-recipe-image')" /> | ||||||
|  |     </v-card-actions> | ||||||
|  |     <v-card-actions class="justify-center"> | ||||||
|  |       <div style="width: 250px"> | ||||||
|  |         <BaseButton :disabled="imageUpload === null" large rounded block :loading="loading" @click="createByOcr" /> | ||||||
|  |       </div> | ||||||
|  |     </v-card-actions> | ||||||
|  |   </div> | ||||||
|  | </template> | ||||||
|  | <script lang="ts"> | ||||||
|  | import { defineComponent, reactive, toRefs, ref, useRouter } from "@nuxtjs/composition-api"; | ||||||
|  | import { AxiosResponse } from "axios"; | ||||||
|  | import { useUserApi } from "~/composables/api"; | ||||||
|  | import { validators } from "~/composables/use-validators"; | ||||||
|  | import { VForm } from "~/types/vuetify"; | ||||||
|  |  | ||||||
|  | export default defineComponent({ | ||||||
|  |   setup() { | ||||||
|  |     const state = reactive({ | ||||||
|  |       error: false, | ||||||
|  |       loading: false, | ||||||
|  |       makeFileRecipeImage: false, | ||||||
|  |     }); | ||||||
|  |     const api = useUserApi(); | ||||||
|  |     const router = useRouter(); | ||||||
|  |  | ||||||
|  |     const imageUpload = ref<File | null>(null); | ||||||
|  |  | ||||||
|  |     function handleResponse(response: AxiosResponse<string> | null) { | ||||||
|  |       if (response?.status !== 201) { | ||||||
|  |         state.error = true; | ||||||
|  |         state.loading = false; | ||||||
|  |         return; | ||||||
|  |       } | ||||||
|  |       router.push(`/recipe/${response.data}/ocr-editor`); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     const domCreateByOcr = ref<VForm | null>(null); | ||||||
|  |  | ||||||
|  |     async function createByOcr() { | ||||||
|  |       if (imageUpload.value === null) return; // Should never be true due to circumstances | ||||||
|  |       state.loading = true; | ||||||
|  |       const { response } = await api.recipes.createFromOcr(imageUpload.value, state.makeFileRecipeImage); | ||||||
|  |       // @ts-ignore returns a string and not a full Recipe | ||||||
|  |       handleResponse(response); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     return { | ||||||
|  |       domCreateByOcr, | ||||||
|  |       createByOcr, | ||||||
|  |       ...toRefs(state), | ||||||
|  |       validators, | ||||||
|  |       imageUpload, | ||||||
|  |     }; | ||||||
|  |   }, | ||||||
|  | }); | ||||||
|  | </script> | ||||||
							
								
								
									
										21
									
								
								frontend/types/api-types/ocr.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										21
									
								
								frontend/types/api-types/ocr.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,21 @@ | |||||||
|  | /* tslint:disable */ | ||||||
|  | /* eslint-disable */ | ||||||
|  | /** | ||||||
|  | /* This file was automatically generated from pydantic models by running pydantic2ts. | ||||||
|  | /* Do not modify it by hand - just update the pydantic models and then re-run the script | ||||||
|  | */ | ||||||
|  |  | ||||||
|  | export interface OcrTsvResponse { | ||||||
|  |   level: number; | ||||||
|  |   pageNum: number; | ||||||
|  |   blockNum: number; | ||||||
|  |   parNum: number; | ||||||
|  |   lineNum: number; | ||||||
|  |   wordNum: number; | ||||||
|  |   left: number; | ||||||
|  |   top: number; | ||||||
|  |   width: number; | ||||||
|  |   height: number; | ||||||
|  |   conf: number; | ||||||
|  |   text: string; | ||||||
|  | } | ||||||
| @@ -214,6 +214,7 @@ export interface Recipe { | |||||||
|     [k: string]: unknown; |     [k: string]: unknown; | ||||||
|   }; |   }; | ||||||
|   comments?: RecipeCommentOut[]; |   comments?: RecipeCommentOut[]; | ||||||
|  |   isOcrRecipe?: boolean; | ||||||
| } | } | ||||||
| export interface RecipeTool { | export interface RecipeTool { | ||||||
|   id: string; |   id: string; | ||||||
|   | |||||||
							
								
								
									
										4
									
								
								frontend/types/components.d.ts
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										4
									
								
								frontend/types/components.d.ts
									
									
									
									
										vendored
									
									
								
							| @@ -30,10 +30,6 @@ import AdvancedOnly from "@/components/global/AdvancedOnly.vue"; | |||||||
| import BasePageTitle from "@/components/global/BasePageTitle.vue"; | import BasePageTitle from "@/components/global/BasePageTitle.vue"; | ||||||
| import ButtonLink from "@/components/global/ButtonLink.vue"; | import ButtonLink from "@/components/global/ButtonLink.vue"; | ||||||
|  |  | ||||||
| import TheSnackbar from "@/components/layout/TheSnackbar.vue"; |  | ||||||
| import AppHeader from "@/components/layout/AppHeader.vue"; |  | ||||||
| import AppSidebar from "@/components/layout/AppSidebar.vue"; |  | ||||||
| import AppFooter from "@/components/layout/AppFooter.vue"; |  | ||||||
|  |  | ||||||
| declare module "vue" { | declare module "vue" { | ||||||
|   export interface GlobalComponents { |   export interface GlobalComponents { | ||||||
|   | |||||||
							
								
								
									
										73
									
								
								frontend/types/ocr-types.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										73
									
								
								frontend/types/ocr-types.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,73 @@ | |||||||
|  | import { OcrTsvResponse } from "~/types/api-types/ocr"; | ||||||
|  | import { Recipe } from "~/types/api-types/recipe"; | ||||||
|  |  | ||||||
|  | export type CanvasRect = { | ||||||
|  |   startX: number; | ||||||
|  |   startY: number; | ||||||
|  |   w: number; | ||||||
|  |   h: number; | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | export type ImagePosition = { | ||||||
|  |   sx: number; | ||||||
|  |   sy: number; | ||||||
|  |   sWidth: number; | ||||||
|  |   sHeight: number; | ||||||
|  |   dx: number; | ||||||
|  |   dy: number; | ||||||
|  |   dWidth: number; | ||||||
|  |   dHeight: number; | ||||||
|  |   scale: number; | ||||||
|  |   panStartPoint: { | ||||||
|  |     x: number; | ||||||
|  |     y: number; | ||||||
|  |   }; | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | export type Mouse = { | ||||||
|  |   current: { | ||||||
|  |     x: number; | ||||||
|  |     y: number; | ||||||
|  |   }; | ||||||
|  |   down: boolean; | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | // https://stackoverflow.com/questions/58434389/export typescript-deep-keyof-of-a-nested-object/58436959#58436959 | ||||||
|  | type Prev = [never, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, ...0[]]; | ||||||
|  |  | ||||||
|  | type Join<K, P> = K extends string | number | ||||||
|  |   ? P extends string | number | ||||||
|  |     ? `${K}${"" extends P ? "" : "."}${P}` | ||||||
|  |     : never | ||||||
|  |   : never; | ||||||
|  |  | ||||||
|  | export type Leaves<T, D extends number = 10> = [D] extends [never] | ||||||
|  |   ? never | ||||||
|  |   : T extends object | ||||||
|  |   ? { [K in keyof T]-?: Join<K, Leaves<T[K], Prev[D]>> }[keyof T] | ||||||
|  |   : ""; | ||||||
|  |  | ||||||
|  | export type Paths<T, D extends number = 10> = [D] extends [never] | ||||||
|  |   ? never | ||||||
|  |   : T extends object | ||||||
|  |   ? { | ||||||
|  |       [K in keyof T]-?: K extends string | number ? `${K}` | Join<K, Paths<T[K], Prev[D]>> : never; | ||||||
|  |     }[keyof T] | ||||||
|  |   : ""; | ||||||
|  |  | ||||||
|  | export type SelectedRecipeLeaves = Leaves<Recipe>; | ||||||
|  |  | ||||||
|  | export type CanvasModes = "selection" | "panAndZoom"; | ||||||
|  |  | ||||||
|  | export type SelectedTextSplitModes = keyof OcrTsvResponse | "flatten"; | ||||||
|  |  | ||||||
|  | export type ToolbarIcons<T extends string> = { | ||||||
|  |   sectionTitle: string; | ||||||
|  |   eventHandler(mode: T): void; | ||||||
|  |   highlight: T; | ||||||
|  |   icons: { | ||||||
|  |     name: T; | ||||||
|  |     icon: string; | ||||||
|  |     tooltip: string; | ||||||
|  |   }[]; | ||||||
|  | }[]; | ||||||
| @@ -125,4 +125,11 @@ export interface Icon { | |||||||
|   back: string; |   back: string; | ||||||
|   slotMachine: string; |   slotMachine: string; | ||||||
|   chevronDown: string; |   chevronDown: string; | ||||||
|  |  | ||||||
|  |   // Ocr toolbar | ||||||
|  |   selectMode: string; | ||||||
|  |   panAndZoom: string; | ||||||
|  |   preserveLines: string; | ||||||
|  |   preserveBlocks: string; | ||||||
|  |   flatten: string; | ||||||
| } | } | ||||||
|   | |||||||
| @@ -118,6 +118,10 @@ import { | |||||||
|   mdiHelpCircleOutline, |   mdiHelpCircleOutline, | ||||||
|   mdiDocker, |   mdiDocker, | ||||||
|   mdiUndo, |   mdiUndo, | ||||||
|  |   mdiSelectionDrag, | ||||||
|  |   mdiCursorMove, | ||||||
|  |   mdiText, | ||||||
|  |   mdiTextBoxOutline, | ||||||
| } from "@mdi/js"; | } from "@mdi/js"; | ||||||
|  |  | ||||||
| export const icons = { | export const icons = { | ||||||
| @@ -253,4 +257,12 @@ export const icons = { | |||||||
|   slotMachine: mdiSlotMachine, |   slotMachine: mdiSlotMachine, | ||||||
|   chevronDown: mdiChevronDown, |   chevronDown: mdiChevronDown, | ||||||
|   chevronRight: mdiChevronRight, |   chevronRight: mdiChevronRight, | ||||||
|  |  | ||||||
|  |   // Ocr toolbar | ||||||
|  |   selectMode: mdiSelectionDrag, | ||||||
|  |   panAndZoom: mdiCursorMove, | ||||||
|  |   preserveLines: mdiText, | ||||||
|  |   preserveBlocks: mdiTextBoxOutline, | ||||||
|  |   flatten: mdiMinus, | ||||||
|  |  | ||||||
| }; | }; | ||||||
|   | |||||||
| @@ -104,6 +104,7 @@ class RecipeModel(SqlAlchemyBase, BaseMixins): | |||||||
|     rating = sa.Column(sa.Integer) |     rating = sa.Column(sa.Integer) | ||||||
|     org_url = sa.Column(sa.String) |     org_url = sa.Column(sa.String) | ||||||
|     extras: list[ApiExtras] = orm.relationship("ApiExtras", cascade="all, delete-orphan") |     extras: list[ApiExtras] = orm.relationship("ApiExtras", cascade="all, delete-orphan") | ||||||
|  |     is_ocr_recipe = sa.Column(sa.Boolean, default=False) | ||||||
|  |  | ||||||
|     # Time Stamp Properties |     # Time Stamp Properties | ||||||
|     date_added = sa.Column(sa.Date, default=datetime.date.today) |     date_added = sa.Column(sa.Date, default=datetime.date.today) | ||||||
|   | |||||||
| @@ -7,6 +7,7 @@ from . import ( | |||||||
|     comments, |     comments, | ||||||
|     explore, |     explore, | ||||||
|     groups, |     groups, | ||||||
|  |     ocr, | ||||||
|     organizers, |     organizers, | ||||||
|     parser, |     parser, | ||||||
|     recipe, |     recipe, | ||||||
| @@ -31,3 +32,4 @@ router.include_router(unit_and_foods.router) | |||||||
| router.include_router(admin.router) | router.include_router(admin.router) | ||||||
| router.include_router(validators.router) | router.include_router(validators.router) | ||||||
| router.include_router(explore.router) | router.include_router(explore.router) | ||||||
|  | router.include_router(ocr.router) | ||||||
|   | |||||||
							
								
								
									
										7
									
								
								mealie/routes/ocr/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										7
									
								
								mealie/routes/ocr/__init__.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,7 @@ | |||||||
|  | from fastapi import APIRouter | ||||||
|  |  | ||||||
|  | from . import pytesseract | ||||||
|  |  | ||||||
|  | router = APIRouter(prefix="/ocr") | ||||||
|  |  | ||||||
|  | router.include_router(pytesseract.router) | ||||||
							
								
								
									
										37
									
								
								mealie/routes/ocr/pytesseract.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										37
									
								
								mealie/routes/ocr/pytesseract.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,37 @@ | |||||||
|  | from fastapi import APIRouter, File | ||||||
|  |  | ||||||
|  | from mealie.routes._base import BaseUserController, controller | ||||||
|  | from mealie.schema.ocr.ocr import OcrAssetReq, OcrTsvResponse | ||||||
|  | from mealie.services.ocr.pytesseract import OcrService | ||||||
|  | from mealie.services.recipe.recipe_data_service import RecipeDataService | ||||||
|  | from mealie.services.recipe.recipe_service import RecipeService | ||||||
|  |  | ||||||
|  | router = APIRouter() | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @controller(router) | ||||||
|  | class OCRController(BaseUserController): | ||||||
|  |     def __init__(self): | ||||||
|  |         self.ocr_service = OcrService() | ||||||
|  |  | ||||||
|  |     @router.post("/", response_model=str) | ||||||
|  |     def image_to_string(self, file: bytes = File(...)): | ||||||
|  |         return self.ocr_service.image_to_string(file) | ||||||
|  |  | ||||||
|  |     @router.post("/file-to-tsv", response_model=list[OcrTsvResponse]) | ||||||
|  |     def file_to_tsv(self, file: bytes = File(...)): | ||||||
|  |         tsv = self.ocr_service.image_to_tsv(file) | ||||||
|  |         return self.ocr_service.format_tsv_output(tsv) | ||||||
|  |  | ||||||
|  |     @router.post("/asset-to-tsv", response_model=list[OcrTsvResponse]) | ||||||
|  |     def asset_to_tsv(self, req: OcrAssetReq): | ||||||
|  |         recipe_service = RecipeService(self.repos, self.user, self.group) | ||||||
|  |         recipe = recipe_service._get_recipe(req.recipe_slug) | ||||||
|  |         if recipe.id is None: | ||||||
|  |             return [] | ||||||
|  |         data_service = RecipeDataService(recipe.id, recipe.group_id) | ||||||
|  |         asset_path = data_service.dir_assets.joinpath(req.asset_name) | ||||||
|  |         file = open(asset_path, "rb") | ||||||
|  |         tsv = self.ocr_service.image_to_tsv(file.read()) | ||||||
|  |  | ||||||
|  |         return self.ocr_service.format_tsv_output(tsv) | ||||||
| @@ -33,7 +33,10 @@ from mealie.schema.recipe.recipe import ( | |||||||
|     RecipeSummary, |     RecipeSummary, | ||||||
| ) | ) | ||||||
| from mealie.schema.recipe.recipe_asset import RecipeAsset | from mealie.schema.recipe.recipe_asset import RecipeAsset | ||||||
|  | from mealie.schema.recipe.recipe_ingredient import RecipeIngredient | ||||||
| from mealie.schema.recipe.recipe_scraper import ScrapeRecipeTest | from mealie.schema.recipe.recipe_scraper import ScrapeRecipeTest | ||||||
|  | from mealie.schema.recipe.recipe_settings import RecipeSettings | ||||||
|  | from mealie.schema.recipe.recipe_step import RecipeStep | ||||||
| from mealie.schema.recipe.request_helpers import RecipeZipTokenResponse, UpdateImageResponse | from mealie.schema.recipe.request_helpers import RecipeZipTokenResponse, UpdateImageResponse | ||||||
| from mealie.schema.response.responses import ErrorResponse | from mealie.schema.response.responses import ErrorResponse | ||||||
| from mealie.services import urls | from mealie.services import urls | ||||||
| @@ -435,3 +438,37 @@ class RecipeController(BaseRecipeController): | |||||||
|         self.mixins.update_one(recipe, slug) |         self.mixins.update_one(recipe, slug) | ||||||
|  |  | ||||||
|         return asset_in |         return asset_in | ||||||
|  |  | ||||||
|  |     # ================================================================================================================== | ||||||
|  |     # OCR | ||||||
|  |     @router.post("/create-ocr", status_code=201, response_model=str) | ||||||
|  |     def create_recipe_ocr( | ||||||
|  |         self, extension: str = Form(...), file: UploadFile = File(...), makefilerecipeimage: bool = Form(...) | ||||||
|  |     ): | ||||||
|  |         """Takes an image and creates a recipe based on the image""" | ||||||
|  |         slug = self.service.create_one( | ||||||
|  |             Recipe( | ||||||
|  |                 name="New OCR Recipe", | ||||||
|  |                 recipe_ingredient=[RecipeIngredient(note="", title=None, unit=None, food=None, original_text=None)], | ||||||
|  |                 recipe_instructions=[RecipeStep(text="")], | ||||||
|  |                 is_ocr_recipe=True, | ||||||
|  |                 settings=RecipeSettings(show_assets=True), | ||||||
|  |                 id=None, | ||||||
|  |                 image=None, | ||||||
|  |                 recipe_yield=None, | ||||||
|  |                 rating=None, | ||||||
|  |                 orgURL=None, | ||||||
|  |                 date_added=None, | ||||||
|  |                 date_updated=None, | ||||||
|  |                 created_at=None, | ||||||
|  |                 update_at=None, | ||||||
|  |                 nutrition=None, | ||||||
|  |             ) | ||||||
|  |         ).slug | ||||||
|  |         RecipeController.upload_recipe_asset(self, slug, "Original recipe image", "", extension, file) | ||||||
|  |         if makefilerecipeimage: | ||||||
|  |             # Get the pointer to the beginning of the file to read it once more | ||||||
|  |             file.file.seek(0) | ||||||
|  |             self.update_recipe_image(slug, file.file.read(), extension) | ||||||
|  |  | ||||||
|  |         return slug | ||||||
|   | |||||||
							
								
								
									
										0
									
								
								mealie/schema/ocr/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										0
									
								
								mealie/schema/ocr/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
								
								
									
										21
									
								
								mealie/schema/ocr/ocr.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										21
									
								
								mealie/schema/ocr/ocr.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,21 @@ | |||||||
|  | from mealie.schema._mealie import MealieModel | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class OcrTsvResponse(MealieModel): | ||||||
|  |     level: int = 0 | ||||||
|  |     page_num: int = 0 | ||||||
|  |     block_num: int = 0 | ||||||
|  |     par_num: int = 0 | ||||||
|  |     line_num: int = 0 | ||||||
|  |     word_num: int = 0 | ||||||
|  |     left: int = 0 | ||||||
|  |     top: int = 0 | ||||||
|  |     width: int = 0 | ||||||
|  |     height: int = 0 | ||||||
|  |     conf: float = 0.0 | ||||||
|  |     text: str = "" | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class OcrAssetReq(MealieModel): | ||||||
|  |     recipe_slug: str | ||||||
|  |     asset_name: str | ||||||
| @@ -141,10 +141,11 @@ class Recipe(RecipeSummary): | |||||||
|     nutrition: Optional[Nutrition] |     nutrition: Optional[Nutrition] | ||||||
|  |  | ||||||
|     # Mealie Specific |     # Mealie Specific | ||||||
|     settings: Optional[RecipeSettings] = RecipeSettings() |     settings: Optional[RecipeSettings] = None | ||||||
|     assets: Optional[list[RecipeAsset]] = [] |     assets: Optional[list[RecipeAsset]] = [] | ||||||
|     notes: Optional[list[RecipeNote]] = [] |     notes: Optional[list[RecipeNote]] = [] | ||||||
|     extras: Optional[dict] = {} |     extras: Optional[dict] = {} | ||||||
|  |     is_ocr_recipe: Optional[bool] = False | ||||||
|  |  | ||||||
|     comments: Optional[list[RecipeCommentOut]] = [] |     comments: Optional[list[RecipeCommentOut]] = [] | ||||||
|  |  | ||||||
|   | |||||||
							
								
								
									
										0
									
								
								mealie/services/ocr/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										0
									
								
								mealie/services/ocr/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
								
								
									
										56
									
								
								mealie/services/ocr/pytesseract.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										56
									
								
								mealie/services/ocr/pytesseract.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,56 @@ | |||||||
|  | from io import BytesIO | ||||||
|  |  | ||||||
|  | import pytesseract | ||||||
|  | from PIL import Image | ||||||
|  |  | ||||||
|  | from mealie.schema.ocr.ocr import OcrTsvResponse | ||||||
|  | from mealie.services._base_service import BaseService | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class OcrService(BaseService): | ||||||
|  |     """ | ||||||
|  |     Class for ocr engines. | ||||||
|  |     """ | ||||||
|  |  | ||||||
|  |     def image_to_string(self, image_data): | ||||||
|  |         """ | ||||||
|  |         Returns a plain text translation of an image | ||||||
|  |         """ | ||||||
|  |         return pytesseract.image_to_string(Image.open(image_data)) | ||||||
|  |  | ||||||
|  |     def image_to_tsv(self, image_data, lang=None): | ||||||
|  |         """ | ||||||
|  |         Returns the pytesseract default tsv output | ||||||
|  |         """ | ||||||
|  |         if lang is not None: | ||||||
|  |             return pytesseract.image_to_data(Image.open(BytesIO(image_data)), lang=lang) | ||||||
|  |  | ||||||
|  |         return pytesseract.image_to_data(Image.open(BytesIO(image_data))) | ||||||
|  |  | ||||||
|  |     def format_tsv_output(self, tsv: str) -> list[OcrTsvResponse]: | ||||||
|  |         """ | ||||||
|  |         Returns a OcrTsvResponse from a default pytesseract tsv output | ||||||
|  |         """ | ||||||
|  |         lines = tsv.split("\n") | ||||||
|  |         titles = [t.strip() for t in lines[0].split("\t")] | ||||||
|  |         response: list[OcrTsvResponse] = [] | ||||||
|  |  | ||||||
|  |         for i in range(1, len(lines)): | ||||||
|  |             if lines[i] == "": | ||||||
|  |                 continue | ||||||
|  |  | ||||||
|  |             line = OcrTsvResponse() | ||||||
|  |             for key, value in zip(titles, lines[i].split("\t")): | ||||||
|  |                 if key == "text": | ||||||
|  |                     setattr(line, key, value.strip()) | ||||||
|  |                 elif key == "conf": | ||||||
|  |                     setattr(line, key, float(value.strip())) | ||||||
|  |                 elif key in OcrTsvResponse.__fields__: | ||||||
|  |                     setattr(line, key, int(value.strip())) | ||||||
|  |                 else: | ||||||
|  |                     continue | ||||||
|  |  | ||||||
|  |             if isinstance(line, OcrTsvResponse): | ||||||
|  |                 response.append(line) | ||||||
|  |  | ||||||
|  |         return response | ||||||
| @@ -111,6 +111,8 @@ class RecipeService(BaseService): | |||||||
|             additional_attrs=create_data.dict(), |             additional_attrs=create_data.dict(), | ||||||
|         ) |         ) | ||||||
|  |  | ||||||
|  |         if isinstance(create_data, CreateRecipe) or create_data.settings is None: | ||||||
|  |             if self.group.preferences is not None: | ||||||
|                 data.settings = RecipeSettings( |                 data.settings = RecipeSettings( | ||||||
|                     public=self.group.preferences.recipe_public, |                     public=self.group.preferences.recipe_public, | ||||||
|                     show_nutrition=self.group.preferences.recipe_show_nutrition, |                     show_nutrition=self.group.preferences.recipe_show_nutrition, | ||||||
| @@ -119,6 +121,8 @@ class RecipeService(BaseService): | |||||||
|                     disable_comments=self.group.preferences.recipe_disable_comments, |                     disable_comments=self.group.preferences.recipe_disable_comments, | ||||||
|                     disable_amount=self.group.preferences.recipe_disable_amount, |                     disable_amount=self.group.preferences.recipe_disable_amount, | ||||||
|                 ) |                 ) | ||||||
|  |             else: | ||||||
|  |                 data.settings = RecipeSettings() | ||||||
|  |  | ||||||
|         return self.repos.recipes.create(data) |         return self.repos.recipes.create(data) | ||||||
|  |  | ||||||
|   | |||||||
							
								
								
									
										18
									
								
								poetry.lock
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										18
									
								
								poetry.lock
									
									
									
										generated
									
									
									
								
							| @@ -821,7 +821,7 @@ requests = ["requests"] | |||||||
| name = "packaging" | name = "packaging" | ||||||
| version = "21.3" | version = "21.3" | ||||||
| description = "Core utilities for Python packages" | description = "Core utilities for Python packages" | ||||||
| category = "dev" | category = "main" | ||||||
| optional = false | optional = false | ||||||
| python-versions = ">=3.6" | python-versions = ">=3.6" | ||||||
|  |  | ||||||
| @@ -1083,6 +1083,18 @@ category = "dev" | |||||||
| optional = false | optional = false | ||||||
| python-versions = ">=3.7" | python-versions = ">=3.7" | ||||||
|  |  | ||||||
|  | [[package]] | ||||||
|  | name = "pytesseract" | ||||||
|  | version = "0.3.9" | ||||||
|  | description = "Python-tesseract is a python wrapper for Google's Tesseract-OCR" | ||||||
|  | category = "main" | ||||||
|  | optional = false | ||||||
|  | python-versions = ">=3.7" | ||||||
|  |  | ||||||
|  | [package.dependencies] | ||||||
|  | packaging = ">=21.3" | ||||||
|  | Pillow = ">=8.0.0" | ||||||
|  |  | ||||||
| [[package]] | [[package]] | ||||||
| name = "pytest" | name = "pytest" | ||||||
| version = "6.2.5" | version = "6.2.5" | ||||||
| @@ -2309,6 +2321,10 @@ pyrsistent = [ | |||||||
|     {file = "pyrsistent-0.18.1-cp39-cp39-win_amd64.whl", hash = "sha256:e24a828f57e0c337c8d8bb9f6b12f09dfdf0273da25fda9e314f0b684b415a07"}, |     {file = "pyrsistent-0.18.1-cp39-cp39-win_amd64.whl", hash = "sha256:e24a828f57e0c337c8d8bb9f6b12f09dfdf0273da25fda9e314f0b684b415a07"}, | ||||||
|     {file = "pyrsistent-0.18.1.tar.gz", hash = "sha256:d4d61f8b993a7255ba714df3aca52700f8125289f84f704cf80916517c46eb96"}, |     {file = "pyrsistent-0.18.1.tar.gz", hash = "sha256:d4d61f8b993a7255ba714df3aca52700f8125289f84f704cf80916517c46eb96"}, | ||||||
| ] | ] | ||||||
|  | pytesseract = [ | ||||||
|  |     {file = "pytesseract-0.3.9-py2.py3-none-any.whl", hash = "sha256:fecda37d1e4eaf744c657cd03a5daab4eb97c61506ac5550274322c8ae32eca2"}, | ||||||
|  |     {file = "pytesseract-0.3.9.tar.gz", hash = "sha256:7e2bafc7f48d1bb71443ce4633a56f5e21925a98f220a36c336297edcd1956d0"}, | ||||||
|  | ] | ||||||
| pytest = [ | pytest = [ | ||||||
|     {file = "pytest-6.2.5-py3-none-any.whl", hash = "sha256:7310f8d27bc79ced999e760ca304d69f6ba6c6649c0b60fb0e04a4a77cacc134"}, |     {file = "pytest-6.2.5-py3-none-any.whl", hash = "sha256:7310f8d27bc79ced999e760ca304d69f6ba6c6649c0b60fb0e04a4a77cacc134"}, | ||||||
|     {file = "pytest-6.2.5.tar.gz", hash = "sha256:131b36680866a76e6781d13f101efb86cf674ebb9762eb70d3082b6f29889e89"}, |     {file = "pytest-6.2.5.tar.gz", hash = "sha256:131b36680866a76e6781d13f101efb86cf674ebb9762eb70d3082b6f29889e89"}, | ||||||
|   | |||||||
| @@ -38,6 +38,7 @@ python-ldap = "^3.3.1" | |||||||
| pydantic = "^1.9.1" | pydantic = "^1.9.1" | ||||||
| tzdata = "^2021.5" | tzdata = "^2021.5" | ||||||
| pyhumps = "^3.5.3" | pyhumps = "^3.5.3" | ||||||
|  | pytesseract = "^0.3.9" | ||||||
|  |  | ||||||
| [tool.poetry.dev-dependencies] | [tool.poetry.dev-dependencies] | ||||||
| pylint = "^2.6.0" | pylint = "^2.6.0" | ||||||
|   | |||||||
							
								
								
									
										
											BIN
										
									
								
								tests/data/images/test-ocr.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										
											BIN
										
									
								
								tests/data/images/test-ocr.png
									
									
									
									
									
										Normal file
									
								
							
										
											Binary file not shown.
										
									
								
							| After Width: | Height: | Size: 11 KiB | 
							
								
								
									
										73
									
								
								tests/data/text/test-ocr.tsv
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										73
									
								
								tests/data/text/test-ocr.tsv
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,73 @@ | |||||||
|  | level	page_num	block_num	par_num	line_num	word_num	left	top	width	height	conf	text | ||||||
|  | 1	1	0	0	0	0	0	0	640	480	-1	 | ||||||
|  | 2	1	1	0	0	0	36	92	582	269	-1	 | ||||||
|  | 3	1	1	1	0	0	36	92	582	92	-1	 | ||||||
|  | 4	1	1	1	1	0	36	92	544	30	-1	 | ||||||
|  | 5	1	1	1	1	1	36	92	60	24	87.137558	This | ||||||
|  | 5	1	1	1	1	2	109	92	20	24	87.137558	is | ||||||
|  | 5	1	1	1	1	3	141	98	15	18	87.823906	a | ||||||
|  | 5	1	1	1	1	4	169	92	32	24	87.823906	lot | ||||||
|  | 5	1	1	1	1	5	212	92	28	24	92.965874	of | ||||||
|  | 5	1	1	1	1	6	251	92	31	24	93.247513	12 | ||||||
|  | 5	1	1	1	1	7	296	92	68	30	92.734741	point | ||||||
|  | 5	1	1	1	1	8	374	93	53	23	92.996040	text | ||||||
|  | 5	1	1	1	1	9	437	93	26	23	93.160057	to | ||||||
|  | 5	1	1	1	1	10	474	93	52	23	92.312637	test | ||||||
|  | 5	1	1	1	1	11	536	92	44	24	92.312637	the | ||||||
|  | 4	1	1	1	2	0	36	126	582	31	-1	 | ||||||
|  | 5	1	1	1	2	1	36	132	45	18	90.505524	ocr | ||||||
|  | 5	1	1	1	2	2	91	126	69	24	90.505524	code | ||||||
|  | 5	1	1	1	2	3	172	126	51	24	91.169167	and | ||||||
|  | 5	1	1	1	2	4	236	132	50	18	89.765854	see | ||||||
|  | 5	1	1	1	2	5	299	126	15	24	85.827324	if | ||||||
|  | 5	1	1	1	2	6	325	126	14	24	93.116241	it | ||||||
|  | 5	1	1	1	2	7	348	126	85	24	92.394562	works | ||||||
|  | 5	1	1	1	2	8	445	132	33	18	30.119690	on | ||||||
|  | 5	1	1	1	2	9	500	126	29	24	30.119690	all | ||||||
|  | 5	1	1	1	2	10	541	127	77	30	92.090988	types | ||||||
|  | 4	1	1	1	3	0	36	160	187	24	-1	 | ||||||
|  | 5	1	1	1	3	1	36	160	28	24	92.476135	of | ||||||
|  | 5	1	1	1	3	2	72	160	41	24	90.919365	file | ||||||
|  | 5	1	1	1	3	3	123	160	100	24	91.360367	format. | ||||||
|  | 3	1	1	2	0	0	36	194	561	167	-1	 | ||||||
|  | 4	1	1	2	1	0	36	194	549	31	-1	 | ||||||
|  | 5	1	1	2	1	1	36	194	55	24	89.098892	The | ||||||
|  | 5	1	1	2	1	2	102	194	75	30	89.098892	quick | ||||||
|  | 5	1	1	2	1	3	189	194	85	24	91.415680	brown | ||||||
|  | 5	1	1	2	1	4	287	194	52	31	91.943085	dog | ||||||
|  | 5	1	1	2	1	5	348	194	108	31	92.167969	jumped | ||||||
|  | 5	1	1	2	1	6	468	200	63	18	91.970985	over | ||||||
|  | 5	1	1	2	1	7	540	194	45	24	92.843704	the | ||||||
|  | 4	1	1	2	2	0	37	228	548	31	-1	 | ||||||
|  | 5	1	1	2	2	1	37	228	55	31	92.262550	lazy | ||||||
|  | 5	1	1	2	2	2	103	228	50	24	92.693161	fox. | ||||||
|  | 5	1	1	2	2	3	165	228	55	24	92.947639	The | ||||||
|  | 5	1	1	2	2	4	232	228	75	30	90.589806	quick | ||||||
|  | 5	1	1	2	2	5	319	228	85	24	91.051247	brown | ||||||
|  | 5	1	1	2	2	6	417	228	51	31	91.925011	dog | ||||||
|  | 5	1	1	2	2	7	478	228	107	31	91.471077	jumped | ||||||
|  | 4	1	1	2	3	0	36	262	561	31	-1	 | ||||||
|  | 5	1	1	2	3	1	36	268	63	18	90.210129	over | ||||||
|  | 5	1	1	2	3	2	109	262	44	24	90.210129	the | ||||||
|  | 5	1	1	2	3	3	165	262	56	31	91.178192	lazy | ||||||
|  | 5	1	1	2	3	4	231	262	50	24	92.794647	fox. | ||||||
|  | 5	1	1	2	3	5	294	262	55	24	91.388016	The | ||||||
|  | 5	1	1	2	3	6	360	262	75	30	92.525742	quick | ||||||
|  | 5	1	1	2	3	7	447	262	85	24	90.425552	brown | ||||||
|  | 5	1	1	2	3	8	545	262	52	31	90.425552	dog | ||||||
|  | 4	1	1	2	4	0	43	296	518	31	-1	 | ||||||
|  | 5	1	1	2	4	1	43	296	107	31	91.759590	jumped | ||||||
|  | 5	1	1	2	4	2	162	302	64	18	92.923576	over | ||||||
|  | 5	1	1	2	4	3	235	296	44	24	92.017929	the | ||||||
|  | 5	1	1	2	4	4	292	296	55	31	91.558884	lazy | ||||||
|  | 5	1	1	2	4	5	357	296	50	24	92.687485	fox. | ||||||
|  | 5	1	1	2	4	6	420	296	55	24	91.922661	The | ||||||
|  | 5	1	1	2	4	7	486	296	75	30	91.870224	quick | ||||||
|  | 4	1	1	2	5	0	37	330	524	31	-1	 | ||||||
|  | 5	1	1	2	5	1	37	330	85	24	92.923935	brown | ||||||
|  | 5	1	1	2	5	2	135	330	52	31	91.468765	dog | ||||||
|  | 5	1	1	2	5	3	196	330	108	31	91.425491	jumped | ||||||
|  | 5	1	1	2	5	4	316	336	63	18	91.489830	over | ||||||
|  | 5	1	1	2	5	5	388	330	45	24	91.740379	the | ||||||
|  | 5	1	1	2	5	6	445	330	55	31	92.110054	lazy | ||||||
|  | 5	1	1	2	5	7	511	330	50	24	93.180054	fox. | ||||||
| 
 | 
							
								
								
									
										9
									
								
								tests/data/text/test-ocr.txt
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										9
									
								
								tests/data/text/test-ocr.txt
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,9 @@ | |||||||
|  | This is a lot of 12 point text to test the | ||||||
|  | ocr code and see if it works on all types | ||||||
|  | of file format. | ||||||
|  |  | ||||||
|  | The quick brown dog jumped over the | ||||||
|  | lazy fox. The quick brown dog jumped | ||||||
|  | over the lazy fox. The quick brown dog | ||||||
|  | jumped over the lazy fox. The quick | ||||||
|  | brown dog jumped over the lazy fox. | ||||||
| @@ -4,7 +4,7 @@ from mealie.core.config import get_app_settings | |||||||
| from mealie.services.backups_v2.alchemy_exporter import AlchemyExporter | from mealie.services.backups_v2.alchemy_exporter import AlchemyExporter | ||||||
|  |  | ||||||
| ALEMBIC_VERSIONS = [ | ALEMBIC_VERSIONS = [ | ||||||
|     {"version_num": "188374910655"}, |     {"version_num": "089bfa50d0ed"}, | ||||||
| ] | ] | ||||||
|  |  | ||||||
|  |  | ||||||
|   | |||||||
							
								
								
									
										58
									
								
								tests/unit_tests/services_tests/test_ocr_service.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										58
									
								
								tests/unit_tests/services_tests/test_ocr_service.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,58 @@ | |||||||
|  | from pathlib import Path | ||||||
|  |  | ||||||
|  | import pytest | ||||||
|  |  | ||||||
|  | from mealie.services.ocr.pytesseract import OcrService | ||||||
|  |  | ||||||
|  | ocr_service = OcrService() | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @pytest.mark.skip("Tesseract is not reliable between environments") | ||||||
|  | def test_image_to_string(): | ||||||
|  |     with open(Path("tests/data/images/test-ocr.png"), "rb") as image: | ||||||
|  |         result = ocr_service.image_to_string(image) | ||||||
|  |         with open(Path("tests/data/text/test-ocr.txt"), "r", encoding="utf-8") as expected_result: | ||||||
|  |             assert result == expected_result.read() | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @pytest.mark.skip("Tesseract is not reliable between environments") | ||||||
|  | def test_image_to_tsv(): | ||||||
|  |     with open(Path("tests/data/images/test-ocr.png"), "rb") as image: | ||||||
|  |         result = ocr_service.image_to_tsv(image.read()) | ||||||
|  |         with open(Path("tests/data/text/test-ocr.tsv"), "r", encoding="utf-8") as expected_result: | ||||||
|  |             assert result == expected_result.read() | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def test_format_tsv_output(): | ||||||
|  |     tsv = " level\tpage_num\tblock_num\tpar_num\tline_num\tword_num\tleft\ttop\twidth\theight\tconf\ttext \n1\t1\t0\t0\t0\t0\t0\t0\t640\t480\t-1\t\n5\t1\t1\t1\t1\t1\t36\t92\t60\t24\t87.137558\tThis" | ||||||
|  |     expected_result = [ | ||||||
|  |         { | ||||||
|  |             "level": 1, | ||||||
|  |             "page_num": 1, | ||||||
|  |             "block_num": 0, | ||||||
|  |             "par_num": 0, | ||||||
|  |             "line_num": 0, | ||||||
|  |             "word_num": 0, | ||||||
|  |             "left": 0, | ||||||
|  |             "top": 0, | ||||||
|  |             "width": 640, | ||||||
|  |             "height": 480, | ||||||
|  |             "conf": -1.0, | ||||||
|  |             "text": "", | ||||||
|  |         }, | ||||||
|  |         { | ||||||
|  |             "level": 5, | ||||||
|  |             "page_num": 1, | ||||||
|  |             "block_num": 1, | ||||||
|  |             "par_num": 1, | ||||||
|  |             "line_num": 1, | ||||||
|  |             "word_num": 1, | ||||||
|  |             "left": 36, | ||||||
|  |             "top": 92, | ||||||
|  |             "width": 60, | ||||||
|  |             "height": 24, | ||||||
|  |             "conf": 87.137558, | ||||||
|  |             "text": "This", | ||||||
|  |         }, | ||||||
|  |     ] | ||||||
|  |     assert ocr_service.format_tsv_output(tsv) == expected_result | ||||||
		Reference in New Issue
	
	Block a user