1
0
Fork 0
mirror of https://github.com/mealie-recipes/mealie.git synced 2025-08-02 20:15:24 +02:00

feat: Remove OCR Support (#2838)

* remove ocr package

* remove tesseract

* remove OCR from app

* remove OCR from tests

* fix docs
This commit is contained in:
Michael Genson 2023-12-14 20:26:43 -06:00 committed by GitHub
parent c48680374d
commit ca9f66ee24
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
34 changed files with 29 additions and 1570 deletions

View file

@ -102,7 +102,6 @@ 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, RecipeTimerMenu, RecipeTimelineBadge },
@ -139,10 +138,6 @@ export default defineComponent({
type: Boolean,
default: false,
},
showOcrButton: {
type: Boolean,
default: false,
},
},
setup(props, context) {
const deleteDialog = ref(false);
@ -175,15 +170,6 @@ 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:

View file

@ -1,390 +0,0 @@
<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>
<RecipePageInstructions
v-model="recipe.recipeInstructions"
:ingredients="recipe.recipeIngredient"
:disable-amount="recipe.settings.disableAmount"
:edit="true"
:recipe="recipe"
:assets.sync="recipe.assets"
@click-instruction-field="setSingleStep"
/>
</v-tab-item>
</v-tabs-items>
</v-col>
</v-row>
</v-container>
</template>
<script lang="ts">
import { defineComponent, ref, onMounted, reactive, toRefs, useContext, useRouter, computed, useRoute } from "@nuxtjs/composition-api";
import { until } from "@vueuse/core";
import { invoke } from "@vueuse/shared";
import draggable from "vuedraggable";
import RecipePageInstructions from "~/components/Domain/Recipe/RecipePage/RecipePageParts/RecipePageInstructions.vue";
import { useUserApi, useStaticRoutes } from "~/composables/api";
import { OcrTsvResponse as NullableOcrTsvResponse } from "~/lib/api/types/ocr";
import { validators } from "~/composables/use-validators";
import { Recipe, RecipeIngredient, RecipeStep } from "~/lib/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 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 "~/lib/api/types/non-generated";
// Temporary Shim until we have a better solution
// https://github.com/phillipdupuis/pydantic-to-typescript/issues/28
type OcrTsvResponse = NoUndefinedField<NullableOcrTsvResponse>;
export default defineComponent({
components: {
RecipeIngredientEditor,
draggable,
BannerExperimental,
RecipeDialogBulkAdd,
RecipePageInstructions,
RecipeOcrEditorPageCanvas,
RecipeOcrEditorPageHelp,
},
props: {
recipe: {
type: Object as () => NoUndefinedField<Recipe>,
required: true,
},
},
setup(props) {
const { $auth } = useContext();
const route = useRoute();
const groupSlug = computed(() => route.value.params.groupSlug || $auth.user?.groupSlug || "");
const router = useRouter();
const api = useUserApi();
const tsv = ref<OcrTsvResponse[]>([]);
const drag = ref(false);
const { i18n } = useContext();
const { recipeAssetPath } = useStaticRoutes();
function assetURL(assetName: string) {
return recipeAssetPath(props.recipe.id, assetName);
}
const state = reactive({
loading: true,
loadingText: i18n.tc("general.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 = i18n.tc("general.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(`/g/${groupSlug.value}/r/${data.slug}`);
}
}
function closeEditor() {
router.push(`/g/${groupSlug.value}/r/${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>

View file

@ -1,488 +0,0 @@
<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.trim() }}
</span>
</v-card>
</template>
<script lang="ts">
import { defineComponent, reactive, useContext, ref, toRefs, watch, onMounted } from "@nuxtjs/composition-api";
import { NoUndefinedField } from "~/lib/api/types/non-generated";
import { OcrTsvResponse as NullableOcrTsvResponse } from "~/lib/api/types/ocr";
import { CanvasModes, SelectedTextSplitModes, ImagePosition, Mouse, CanvasRect, ToolbarIcons } from "~/types/ocr-types";
// Temporary Shim until we have a better solution
// https://github.com/phillipdupuis/pydantic-to-typescript/issues/28
type OcrTsvResponse = NoUndefinedField<NullableOcrTsvResponse>;
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: i18n.tc("ocr-editor.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>

View file

@ -1,54 +0,0 @@
<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"> {{ $t("ocr-editor.help.help") }} </v-toolbar-title>
<v-spacer></v-spacer>
</v-app-bar>
<v-card-text>
<h1> {{ $t("ocr-editor.help.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>

View file

@ -1,3 +0,0 @@
import RecipeOcrEditorPage from "./RecipeOcrEditorPage.vue";
export default RecipeOcrEditorPage;

View file

@ -50,7 +50,6 @@
:logged-in="isOwnGroup"
:open="isEditMode"
:recipe-id="recipe.id"
:show-ocr-button="recipe.isOcrRecipe"
class="ml-auto mt-n8 pb-4"
@close="setMode(PageMode.VIEW)"
@json="toggleEditMode()"
@ -58,13 +57,12 @@
@save="$emit('save')"
@delete="$emit('delete')"
@print="printRecipe"
@ocr="goToOcrEditor"
/>
</div>
</template>
<script lang="ts">
import { defineComponent, useContext, computed, ref, watch, useRouter, useRoute } from "@nuxtjs/composition-api";
import { defineComponent, useContext, computed, ref, watch } from "@nuxtjs/composition-api";
import { useLoggedInState } from "~/composables/use-logged-in-state";
import RecipeRating from "~/components/Domain/Recipe/RecipeRating.vue";
import RecipeLastMade from "~/components/Domain/Recipe/RecipeLastMade.vue";
@ -96,16 +94,12 @@ export default defineComponent({
},
},
setup(props) {
const { $auth, $vuetify } = useContext();
const { $vuetify } = useContext();
const { recipeImage } = useStaticRoutes();
const { imageKey, pageMode, editMode, setMode, toggleEditMode, isEditMode } = usePageState(props.recipe.slug);
const { user } = usePageUser();
const { isOwnGroup } = useLoggedInState();
const route = useRoute();
const groupSlug = computed(() => route.value.params.groupSlug || $auth.user?.groupSlug || "");
const router = useRouter();
function printRecipe() {
window.print();
}
@ -119,10 +113,6 @@ export default defineComponent({
return recipeImage(props.recipe.id, props.recipe.image, imageKey.value);
});
function goToOcrEditor() {
router.push(`/g/${groupSlug.value}/r/${props.recipe.slug}/ocr-editor`);
}
watch(
() => recipeImageUrl.value,
() => {
@ -146,7 +136,6 @@ export default defineComponent({
hideImage,
isEditMode,
recipeImageUrl,
goToOcrEditor,
};
},
});