mirror of
https://github.com/mealie-recipes/mealie.git
synced 2025-08-04 21:15:22 +02: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:
parent
a8f3922907
commit
39adea4ee3
44 changed files with 1659 additions and 34 deletions
|
@ -90,6 +90,7 @@ const SAVE_EVENT = "save";
|
|||
const DELETE_EVENT = "delete";
|
||||
const CLOSE_EVENT = "close";
|
||||
const JSON_EVENT = "json";
|
||||
const OCR_EVENT = "ocr";
|
||||
|
||||
export default defineComponent({
|
||||
components: { RecipeContextMenu, RecipeFavoriteBadge },
|
||||
|
@ -122,8 +123,12 @@ export default defineComponent({
|
|||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
showOcrButton: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
setup(_, context) {
|
||||
setup(props, context) {
|
||||
const deleteDialog = ref(false);
|
||||
|
||||
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) {
|
||||
switch (event) {
|
||||
case CLOSE_EVENT:
|
||||
context.emit(CLOSE_EVENT);
|
||||
context.emit("input", false);
|
||||
break;
|
||||
case SAVE_EVENT:
|
||||
context.emit(SAVE_EVENT);
|
||||
break;
|
||||
case JSON_EVENT:
|
||||
context.emit(JSON_EVENT);
|
||||
break;
|
||||
case DELETE_EVENT:
|
||||
deleteDialog.value = true;
|
||||
break;
|
||||
default:
|
||||
context.emit(event);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -2,7 +2,7 @@
|
|||
<div class="text-center">
|
||||
<v-dialog v-model="dialog" width="800">
|
||||
<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") }}
|
||||
</BaseButton>
|
||||
</template>
|
||||
|
@ -58,10 +58,17 @@
|
|||
<script lang="ts">
|
||||
import { reactive, toRefs, defineComponent, useContext } from "@nuxtjs/composition-api";
|
||||
export default defineComponent({
|
||||
setup(_, context) {
|
||||
props: {
|
||||
inputTextProp: {
|
||||
type: String,
|
||||
required: false,
|
||||
default: "",
|
||||
},
|
||||
},
|
||||
setup(props, context) {
|
||||
const state = reactive({
|
||||
dialog: false,
|
||||
inputText: "",
|
||||
inputText: props.inputTextProp,
|
||||
});
|
||||
|
||||
function splitText() {
|
||||
|
|
|
@ -8,6 +8,7 @@
|
|||
class="mx-1 mt-3 mb-4"
|
||||
:placeholder="$t('recipe.section-title')"
|
||||
style="max-width: 500px"
|
||||
@click="$emit('clickIngredientField', 'title')"
|
||||
>
|
||||
</v-text-field>
|
||||
<v-row :no-gutters="$vuetify.breakpoint.mdAndUp" dense class="d-flex flex-wrap my-1">
|
||||
|
@ -81,7 +82,15 @@
|
|||
</v-col>
|
||||
<v-col sm="12" md="" cols="12">
|
||||
<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">
|
||||
{{ $globals.icons.arrowUpDown }}
|
||||
</v-icon>
|
||||
|
@ -93,12 +102,12 @@
|
|||
:buttons="[
|
||||
{
|
||||
icon: $globals.icons.delete,
|
||||
text: $t('general.delete'),
|
||||
text: $tc('general.delete'),
|
||||
event: 'delete',
|
||||
},
|
||||
{
|
||||
icon: $globals.icons.dotsVertical,
|
||||
text: $t('general.menu'),
|
||||
text: $tc('general.menu'),
|
||||
event: 'open',
|
||||
children: contextMenuOptions,
|
||||
},
|
||||
|
|
|
@ -176,6 +176,7 @@
|
|||
blur: imageUploadMode,
|
||||
}"
|
||||
@drop.stop.prevent="handleImageDrop(index, $event)"
|
||||
@click="$emit('clickInstructionField', `${index}.text`)"
|
||||
>
|
||||
<MarkdownEditor
|
||||
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"
|
||||
:open="isEditMode"
|
||||
:recipe-id="recipe.id"
|
||||
:show-ocr-button="recipe.isOcrRecipe"
|
||||
class="ml-auto mt-n8 pb-4"
|
||||
@close="setMode(PageMode.VIEW)"
|
||||
@json="toggleEditMode()"
|
||||
|
@ -49,12 +50,13 @@
|
|||
@save="$emit('save')"
|
||||
@delete="$emit('delete')"
|
||||
@print="printRecipe"
|
||||
@ocr="goToOcrEditor"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<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 RecipeActionMenu from "~/components/Domain/Recipe/RecipeActionMenu.vue";
|
||||
import RecipeTimeCard from "~/components/Domain/Recipe/RecipeTimeCard.vue";
|
||||
|
@ -82,6 +84,7 @@ export default defineComponent({
|
|||
const { recipeImage } = useStaticRoutes();
|
||||
const { imageKey, pageMode, editMode, setMode, toggleEditMode, isEditMode } = usePageState(props.recipe.slug);
|
||||
const { user } = usePageUser();
|
||||
const router = useRouter();
|
||||
|
||||
function printRecipe() {
|
||||
window.print();
|
||||
|
@ -98,6 +101,10 @@ export default defineComponent({
|
|||
return recipeImage(props.recipe.id, props.recipe.image, imageKey.value);
|
||||
});
|
||||
|
||||
function goToOcrEditor() {
|
||||
router.push("/recipe/" + props.recipe.slug + "/ocr-editor");
|
||||
}
|
||||
|
||||
watch(
|
||||
() => recipeImageUrl.value,
|
||||
() => {
|
||||
|
@ -120,6 +127,7 @@ export default defineComponent({
|
|||
hideImage,
|
||||
isEditMode,
|
||||
recipeImageUrl,
|
||||
goToOcrEditor,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue