([\s\S]*?)<\/tr>/g;
+ let tr: RegExpExecArray | null;
+ while ((tr = trRe.exec(html))) {
+ const cells: { text: string; colspan: number; rowspan: number }[] = [];
+ const tdRe = /]*)>([\s\S]*?)<\/td>/g;
+ let td: RegExpExecArray | null;
+ while ((td = tdRe.exec(tr[1]!))) {
+ cells.push({
+ text: td[2] ?? '',
+ colspan: Number(/colspan="(\d+)"/.exec(td[1] ?? '')?.[1] ?? 1),
+ rowspan: Number(/rowspan="(\d+)"/.exec(td[1] ?? '')?.[1] ?? 1),
+ });
+ }
+ raw.push(cells);
+ }
+ const out: Cell[][] = raw.map(() => []);
+ const occupied: boolean[][] = [];
+ for (let r = 0; r < raw.length; r++) {
+ let c = 0;
+ const spacer = () => {
+ out[r]!.push({ text: '', colspan: 1 });
+ c++;
+ };
+ for (const cell of raw[r]!) {
+ while (occupied[r]?.[c]) spacer();
+ out[r]!.push({ text: cell.text, colspan: cell.colspan });
+ for (let dr = 1; dr < cell.rowspan; dr++) {
+ occupied[r + dr] ??= [];
+ for (let dc = 0; dc < cell.colspan; dc++) {
+ occupied[r + dr]![c + dc] = true;
+ }
+ }
+ c += cell.colspan;
+ }
+ while (occupied[r]?.[c]) spacer();
+ }
+ return out;
+}
+
+function TableView({ html }: { html: string }) {
+ const rows = parseTable(html);
+ if (rows.length === 0) {
+ return {html} ;
+ }
+ return (
+
+
+ {rows.map((cells, r) => (
+
+ {cells.map((c, i) => (
+
+ {c.text}
+
+ ))}
+
+ ))}
+
+
+ );
+}
+
+function OCRContent() {
+ const [selectedIdx, setSelectedIdx] = useState(0);
+ const [vertical, setVertical] = useState(false);
+ const [layoutOn, setLayoutOn] = useState(false);
+ const [documentOn, setDocumentOn] = useState(false);
+ const [orientation, setOrientation] = useState(true);
+ const [tables, setTables] = useState(true);
+ const [dewarp, setDewarp] = useState(false); // off: only helps warped photos
+ const [imageUri, setImageUri] = useState(null);
+ const [isProcessing, setIsProcessing] = useState(false);
+ const [detections, setDetections] = useState([]);
+ const [blocks, setBlocks] = useState[]>([]);
+ // The corrected frame the result boxes are relative to (for the overlay).
+ const [processed, setProcessed] = useState(null);
+ const [wallMs, setWallMs] = useState(null);
+ const [error, setError] = useState(null);
+
+ const selected = OCR_MODELS[selectedIdx]!;
+ const skiaImage = useImage(imageUri, (err) => setError(err.message || String(err)));
+
+ const config = {
+ ...selected.base,
+ ...(layoutOn ? { layout: models.layoutDetection.PP_DOCLAYOUT[selected.backend] } : {}),
+ ...(documentOn ? { documentModels: models.documentModels.PP_HELPERS[selected.backend] } : {}),
+ };
+
+ const { isReady, downloadProgress, error: loadError, runOcr } = useOcr(config);
+
+ const resetResults = () => {
+ setDetections([]);
+ setBlocks([]);
+ setProcessed(null);
+ setWallMs(null);
+ };
+
+ const handlePick = async (useCamera: boolean) => {
+ setError(null);
+ try {
+ const uri = await getImage(useCamera);
+ if (uri) {
+ setImageUri(uri);
+ resetResults();
+ }
+ } catch (e) {
+ setError(e instanceof Error ? e.message : String(e));
+ }
+ };
+
+ const run = async () => {
+ if (!skiaImage || !runOcr) return;
+ setIsProcessing(true);
+ setError(null);
+ try {
+ const pixels = skiaImage.readPixels(0, 0, {
+ width: skiaImage.width(),
+ height: skiaImage.height(),
+ colorType: ColorType.RGBA_8888,
+ alphaType: AlphaType.Unpremul,
+ });
+ if (!(pixels instanceof Uint8Array)) throw new Error('Expected Uint8Array from readPixels');
+ const start = Date.now();
+ const out = await runOcr(
+ {
+ data: pixels,
+ width: skiaImage.width(),
+ height: skiaImage.height(),
+ format: 'rgba' as const,
+ layout: 'hwc' as const,
+ },
+ {
+ vertical,
+ orientation: documentOn && orientation,
+ dewarp: documentOn && dewarp,
+ tables: documentOn && tables,
+ }
+ );
+ setWallMs(Date.now() - start);
+ setDetections(out.detections);
+ setBlocks(out.blocks);
+ const frame = out.image;
+ const frameImage = Skia.Image.MakeImage(
+ {
+ width: frame.width,
+ height: frame.height,
+ colorType: ColorType.RGBA_8888,
+ // Match the AlphaType readPixels used above — the pipeline keeps alpha
+ // as-is, so a mismatched mode here would tint anything with transparency.
+ alphaType: AlphaType.Unpremul,
+ },
+ Skia.Data.fromBytes(frame.data),
+ frame.width * 4
+ );
+ setProcessed(frameImage ?? null);
+ } catch (e) {
+ setError(e instanceof Error ? e.message : String(e));
+ } finally {
+ setIsProcessing(false);
+ }
+ };
+
+ const activeError = loadError ? String(loadError) : error;
+ const boxes = useMemo(() => detections.map((d) => d.quad), [detections]);
+ const showBlocks = layoutOn && blocks.length > 0;
+ const busy = isProcessing || !isReady;
+
+ return (
+
+
+ Detect and recognize text on-device. Turn on Layout to group text into reading-ordered
+ blocks, and Document helpers for orientation, table structure and dewarp.
+
+
+ {
+ setSelectedIdx(idx);
+ resetResults();
+ setError(null);
+ }}
+ />
+
+
+
+
+ {documentOn && (
+ <>
+
+
+
+ >
+ )}
+
+
+
+ handlePick(false)}
+ />
+
+
+ handlePick(false)} variant="secondary" />
+ handlePick(true)} variant="secondary" />
+
+
+
+
+
+ {wallMs !== null && (
+
+ Performance
+
+
+
+ {wallMs}
+ ms
+
+ Wall time
+
+
+ {showBlocks ? blocks.length : detections.length}
+ {showBlocks ? 'Blocks' : 'Regions read'}
+
+
+
+ )}
+
+ {showBlocks ? (
+
+ Blocks ({blocks.length})
+ {blocks.map((b, i) => (
+
+
+ {b.regionType}
+ {b.isTable ? ' · table' : ''}
+
+ {b.isTable && b.tableHtml ? (
+
+ ) : (
+ {b.text}
+ )}
+
+ ))}
+
+ ) : detections.length > 0 ? (
+
+ Detected text ({detections.length})
+ {detections.map((d, i) => (
+
+
+ {d.text}
+
+ {Math.round(d.confidence * 100)}%
+
+ ))}
+
+ ) : null}
+
+ );
+}
+
+function Toggle({
+ label,
+ value,
+ onChange,
+ hint,
+ indent,
+ disabled,
+}: {
+ label: string;
+ value: boolean;
+ onChange: (v: boolean) => void;
+ hint?: string;
+ indent?: boolean;
+ disabled?: boolean;
+}) {
+ return (
+
+
+ {label}
+ {hint ? {hint} : null}
+
+
+
+ );
+}
+
+export default function OCRScreen() {
+ return (
+
+
+
+ );
+}
+
+const styles = StyleSheet.create({
+ toggleRow: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ justifyContent: 'space-between',
+ width: '100%',
+ marginBottom: 8,
+ },
+ toggleIndent: { paddingLeft: 16 },
+ toggleText: { flex: 1, marginRight: 12 },
+ toggleLabel: { fontSize: 15, fontWeight: '600', color: ColorPalette.strongPrimary },
+ toggleHint: { fontSize: 12, color: '#868e96', marginTop: 2 },
+ statsCard: {
+ width: '100%',
+ backgroundColor: '#fff',
+ borderRadius: 12,
+ padding: 16,
+ marginVertical: 16,
+ borderWidth: 1,
+ borderColor: '#e9ecef',
+ },
+ statsTitle: {
+ fontSize: 12,
+ fontWeight: '700',
+ letterSpacing: 1,
+ color: '#868e96',
+ textTransform: 'uppercase',
+ marginBottom: 12,
+ },
+ statTiles: { flexDirection: 'row', gap: 12 },
+ tile: {
+ flex: 1,
+ backgroundColor: '#f2f4ff',
+ borderRadius: 10,
+ paddingVertical: 12,
+ paddingHorizontal: 14,
+ },
+ tileValue: { fontSize: 24, fontWeight: '800', color: '#001A72', fontVariant: ['tabular-nums'] },
+ tileUnit: { fontSize: 14, fontWeight: '600', color: '#6b73a3' },
+ tileLabel: { fontSize: 11, color: '#868e96', marginTop: 4 },
+ results: {
+ width: '100%',
+ backgroundColor: '#fff',
+ borderRadius: 12,
+ padding: 16,
+ borderWidth: 1,
+ borderColor: '#e9ecef',
+ },
+ resultsTitle: {
+ fontSize: 16,
+ fontWeight: '600',
+ color: ColorPalette.strongPrimary,
+ marginBottom: 12,
+ },
+ resultRow: {
+ flexDirection: 'row',
+ justifyContent: 'space-between',
+ alignItems: 'center',
+ paddingVertical: 8,
+ borderBottomWidth: 1,
+ borderBottomColor: '#f1f3f5',
+ },
+ resultLabel: { fontSize: 14, color: '#333', flex: 1, marginRight: 8 },
+ resultConfidence: { fontSize: 14, fontWeight: '600', color: '#2b8a3e' },
+ block: { paddingVertical: 8, borderBottomWidth: 1, borderBottomColor: '#f1f3f5' },
+ regionType: {
+ fontSize: 12,
+ fontWeight: '700',
+ color: '#2b8a3e',
+ textTransform: 'uppercase',
+ marginBottom: 4,
+ },
+ blockText: { fontSize: 14, color: '#333' },
+ table: { borderWidth: 1, borderColor: '#ced4da', borderRadius: 4, overflow: 'hidden' },
+ tr: { flexDirection: 'row' },
+ td: {
+ borderWidth: StyleSheet.hairlineWidth,
+ borderColor: '#ced4da',
+ paddingHorizontal: 6,
+ paddingVertical: 4,
+ minWidth: 24,
+ },
+ tdText: { fontSize: 13, color: '#333' },
+});
diff --git a/apps/computer-vision/components/ImageViewport.tsx b/apps/computer-vision/components/ImageViewport.tsx
index 593133eaf6..80498d5b3b 100644
--- a/apps/computer-vision/components/ImageViewport.tsx
+++ b/apps/computer-vision/components/ImageViewport.tsx
@@ -1,9 +1,11 @@
-import React from 'react';
+import React, { useMemo } from 'react';
import { View, Text, StyleSheet, TouchableOpacity, Dimensions } from 'react-native';
import {
Canvas,
Image as SkImage,
BlendColor,
+ Path,
+ Skia,
type SkImage as SkiaImageType,
} from '@shopify/react-native-skia';
@@ -12,6 +14,11 @@ import { theme } from '../theme';
const VIEW_WIDTH = Dimensions.get('window').width - 32;
const VIEW_HEIGHT = Math.round((VIEW_WIDTH * 16) / 9);
+/** A 2D point in the displayed image's pixel coordinates. */
+type Point = { readonly x: number; readonly y: number };
+/** A polygon (e.g. an OCR quad) in the displayed image's pixel coordinates. */
+type Polygon = readonly Point[];
+
export interface ImageViewportProps {
skiaImage: SkiaImageType | null;
overlayImage?: SkiaImageType | null;
@@ -20,6 +27,8 @@ export interface ImageViewportProps {
placeholderText?: string;
overlayOpacity?: number;
children?: React.ReactNode;
+ /** Polygons (in the displayed image's px) to stroke over the image, e.g. OCR quads. */
+ boxes?: readonly Polygon[];
}
export function ImageViewport({
@@ -30,7 +39,31 @@ export function ImageViewport({
placeholderText = 'Tap to select an image from gallery',
overlayOpacity = 0.8,
children,
+ boxes,
}: ImageViewportProps) {
+ // Map image-pixel polygons into canvas space using the same contain-fit
+ // transform Skia uses to draw the image, then build one stroked path.
+ const boxesPath = useMemo(() => {
+ if (!skiaImage || !boxes?.length) return null;
+ const ow = skiaImage.width();
+ const oh = skiaImage.height();
+ if (ow === 0 || oh === 0) return null;
+ const scale = Math.min(VIEW_WIDTH / ow, VIEW_HEIGHT / oh);
+ const dx = (VIEW_WIDTH - ow * scale) / 2;
+ const dy = (VIEW_HEIGHT - oh * scale) / 2;
+
+ const path = Skia.Path.Make();
+ for (const poly of boxes) {
+ if (poly.length < 2) continue;
+ path.moveTo(dx + poly[0]!.x * scale, dy + poly[0]!.y * scale);
+ for (let i = 1; i < poly.length; i++) {
+ path.lineTo(dx + poly[i]!.x * scale, dy + poly[i]!.y * scale);
+ }
+ path.close();
+ }
+ return path;
+ }, [skiaImage, boxes]);
+
if (!skiaImage) {
return (
@@ -76,6 +109,14 @@ export function ImageViewport({
))}
+ {boxesPath && (
+
+ )}
{children}
diff --git a/apps/computer-vision/theme.ts b/apps/computer-vision/theme.ts
index 765ed34ae1..3cf2b2f05f 100644
--- a/apps/computer-vision/theme.ts
+++ b/apps/computer-vision/theme.ts
@@ -22,6 +22,7 @@ export const theme = {
textSecondary: '#000000',
textMuted: '#666666',
textPlaceholder: '#868e96',
+ overlayStroke: '#39FF14',
},
radius: {
small: 8,
diff --git a/packages/react-native-executorch/cpp/core/model.cpp b/packages/react-native-executorch/cpp/core/model.cpp
index 5b9503cb82..19c5f3cc30 100644
--- a/packages/react-native-executorch/cpp/core/model.cpp
+++ b/packages/react-native-executorch/cpp/core/model.cpp
@@ -7,6 +7,7 @@
#include
#include
#include
+#include
#include
#include "dtype.h"
@@ -188,6 +189,169 @@ parseDynamicInputShapes(executorch::extension::Module &module, const std::string
return dynamicShapes;
}
+
+/**
+ * Parses enumerated input-shape constraints for the inputs of a module method.
+ *
+ * Mirrors parseDynamicInputShapes for enumerated-shape backends (e.g. CoreML,
+ * which accepts only a fixed set of whole shapes rather than a continuous
+ * range). The .pte must export a companion method "get_enum_shapes_"
+ * that takes no arguments and returns one output per Tag::Tensor input; each
+ * output is a 2D int32 tensor of shape [numShapes, rank] whose rows are the
+ * legal whole shapes for that input.
+ *
+ * @return A vector aligned 1-to-1 with the method's inputs (empty entries for
+ * non-tensor inputs), or std::nullopt if the companion is not defined.
+ */
+std::optional>
+parseEnumInputShapes(executorch::extension::Module &module, const std::string &methodName) {
+ using executorch::aten::ScalarType;
+
+ const auto companion = std::format("get_enum_shapes_{}", methodName);
+ const auto ctx = companion + ": ";
+
+ auto methodNames = unwrap(ctx + "failed to get method names", module.method_names());
+ // Skip companion methods themselves, and any method without a companion.
+ if (methodName.starts_with("get_enum_shapes_") || !methodNames.contains(companion)) {
+ return std::nullopt;
+ }
+
+ auto methodMeta = unwrap(std::format("{}failed to get meta for method '{}'", ctx, methodName),
+ module.method_meta(methodName));
+
+ size_t expectedTensorInputs = 0;
+ for (size_t i = 0; i < methodMeta.num_inputs(); ++i) {
+ auto tag = unwrap(std::format("{}failed to get tag for input [{}]", ctx, i), methodMeta.input_tag(i));
+ if (tag == executorch::runtime::Tag::Tensor) {
+ expectedTensorInputs++;
+ }
+ }
+
+ auto result = unwrap(ctx + "failed to execute", module.execute(companion));
+ if (result.size() != expectedTensorInputs) {
+ throw std::runtime_error(std::format("{}number of outputs returned ({}) does not match the number of "
+ "tensor inputs declared by method '{}' ({})",
+ ctx, result.size(), methodName, expectedTensorInputs));
+ }
+
+ std::vector enumeratedShapes;
+ enumeratedShapes.reserve(methodMeta.num_inputs());
+ size_t tensorIndex = 0;
+
+ for (size_t i = 0; i < methodMeta.num_inputs(); ++i) {
+ auto tag = unwrap(std::format("{}failed to get tag for input [{}]", ctx, i),
+ methodMeta.input_tag(i));
+
+ if (tag != executorch::runtime::Tag::Tensor) {
+ // Emplace an empty set for non-tensor inputs to keep 1-to-1 index
+ // alignment between enumeratedShapes and the model's inputs vector.
+ enumeratedShapes.emplace_back();
+ continue;
+ }
+
+ const auto &out = result.at(tensorIndex);
+ if (!out.isTensor()) {
+ throw std::runtime_error(std::format("{}output[{}] is not a tensor", ctx, tensorIndex));
+ }
+
+ auto inputMeta = unwrap(std::format("{}failed to get tensor meta for input [{}]", ctx, i),
+ methodMeta.input_tensor_meta(i));
+ const auto rank = inputMeta.sizes().size();
+ const auto shapeTensor = out.toTensor();
+
+ if (shapeTensor.dim() != 2 || shapeTensor.size(1) != static_cast(rank) ||
+ shapeTensor.scalar_type() != ScalarType::Int) {
+ throw std::runtime_error(std::format("{}output[{}] expected to be a 2D int32_t tensor of shape "
+ "[numShapes, {}]",
+ ctx, tensorIndex, rank));
+ }
+
+ const auto numShapes = shapeTensor.size(0);
+ if (numShapes < 1) {
+ throw std::runtime_error(
+ std::format("{}output[{}] declares an empty enumerated shape set", ctx, tensorIndex));
+ }
+ const auto *data = shapeTensor.const_data_ptr();
+ tensor::EnumeratedShapes shapes;
+ shapes.reserve(static_cast(numShapes));
+
+ for (ssize_t s = 0; s < numShapes; ++s) {
+ std::vector dims;
+ dims.reserve(rank);
+ for (size_t axis = 0; axis < rank; ++axis) {
+ const auto dim = data[static_cast(s) * rank + axis];
+ if (dim < 1) {
+ throw std::runtime_error(std::format("{}output[{}], shape {} axis {} is invalid: "
+ "expected dim >= 1 but got {}",
+ ctx, tensorIndex, s, axis, dim));
+ }
+ if (dim > inputMeta.sizes()[axis]) {
+ throw std::runtime_error(std::format("{}output[{}], shape {} axis {} ({}) exceeds "
+ "model metadata upper limit ({})",
+ ctx, tensorIndex, s, axis, dim, inputMeta.sizes()[axis]));
+ }
+ dims.push_back(dim);
+ }
+ shapes.push_back(std::move(dims));
+ }
+
+ enumeratedShapes.push_back(std::move(shapes));
+ ++tensorIndex;
+ }
+
+ return enumeratedShapes;
+}
+
+// Encodes one resolved input constraint onto a JS object: `dims` for dynamic
+// per-dimension ranges, `shapes` for an enumerated set.
+void encodeConstraint(jsi::Runtime &rt, jsi::Object &out, const tensor::ShapeConstraint &constraint) {
+ if (const auto *shape = std::get_if(&constraint)) {
+ auto dims = jsi::Array(rt, shape->size());
+ for (size_t axis = 0; axis < shape->size(); ++axis) {
+ // A SymbolicDim is a range, a fixed size, or a named symbol. Encode a
+ // fixed axis as a degenerate [v, v] range. A named symbol has no numeric
+ // range the runtime resolves it lazily, so we degrade rather than throw
+ // (introspection must not hard-fail on a model execute() handles fine):
+ // emit [0,0] placeholders tagged with the symbol name.
+ int32_t min = 0;
+ int32_t max = 0;
+ int32_t step = 1;
+ const std::string *symbolName = nullptr;
+ const auto &sdim = (*shape)[axis];
+ if (const auto *range = std::get_if(&sdim)) {
+ min = range->min;
+ max = range->max;
+ step = range->step.value_or(1);
+ } else if (const auto *fixed = std::get_if(&sdim)) {
+ min = max = *fixed;
+ } else if (const auto *named = std::get_if(&sdim)) {
+ symbolName = named;
+ }
+ auto dim = jsi::Object(rt);
+ dim.setProperty(rt, "min", static_cast(min));
+ dim.setProperty(rt, "max", static_cast(max));
+ dim.setProperty(rt, "step", static_cast(step));
+ if (symbolName != nullptr) {
+ dim.setProperty(rt, "symbol", jsi::String::createFromUtf8(rt, *symbolName));
+ }
+ dims.setValueAtIndex(rt, axis, dim);
+ }
+ out.setProperty(rt, "kind", jsi::String::createFromUtf8(rt, "range"));
+ out.setProperty(rt, "dims", dims);
+ } else if (const auto *shapes = std::get_if(&constraint)) {
+ auto arr = jsi::Array(rt, shapes->size());
+ for (size_t s = 0; s < shapes->size(); ++s) {
+ const auto &oneShape = (*shapes)[s];
+ auto shapeArr = jsi::Array(rt, oneShape.size());
+ for (size_t axis = 0; axis < oneShape.size(); ++axis) {
+ shapeArr.setValueAtIndex(rt, axis, static_cast(oneShape[axis]));
+ }
+ arr.setValueAtIndex(rt, s, shapeArr);
+ }
+ out.setProperty(rt, "kind", jsi::String::createFromUtf8(rt, "enum"));
+ out.setProperty(rt, "shapes", arr);
+ }
+}
} // namespace
namespace rnexecutorch::core::model {
@@ -208,9 +372,30 @@ ModelHostObject::ModelHostObject(const std::string &modelPath)
auto methodNames = unwrap("Failed to get method names", etModule_->method_names());
for (const auto &methodName : methodNames) {
auto dynamicShapes = parseDynamicInputShapes(*etModule_, methodName);
+ auto enumShapes = parseEnumInputShapes(*etModule_, methodName);
+
+ if (dynamicShapes && enumShapes) {
+ throw std::runtime_error(std::format(
+ "Method '{}' declares both get_dynamic_dims_{} and get_enum_shapes_{} companions; "
+ "a method must use a single shape-constraint kind, not both.",
+ methodName, methodName, methodName));
+ }
+
+ std::vector constraints;
if (dynamicShapes) {
- dynamicInputShapes_.emplace(methodName, std::move(*dynamicShapes));
+ constraints.reserve(dynamicShapes->size());
+ for (auto &shape : *dynamicShapes) {
+ constraints.emplace_back(std::move(shape));
+ }
+ } else if (enumShapes) {
+ constraints.reserve(enumShapes->size());
+ for (auto &shape : *enumShapes) {
+ constraints.emplace_back(std::move(shape));
+ }
+ } else {
+ continue;
}
+ inputShapeConstraints_.emplace(methodName, std::move(constraints));
}
}
@@ -322,6 +507,59 @@ jsi::Value ModelHostObject::get(jsi::Runtime &rt, const jsi::PropNameID &name) {
return jsi::Function::createFromHostFunction(rt, jsi::PropNameID::forAscii(rt, "getMethodMeta"), 1, fnBody);
}
+ if (nameStr == "getInputShapeConstraints") {
+ auto self = shared_from_this();
+ auto fnBody = [self](jsi::Runtime &rt, const jsi::Value & /*thisVal*/, const jsi::Value *args, size_t count) -> jsi::Value {
+ if (count != 1) {
+ throw jsi::JSError(rt, "getInputShapeConstraints: Usage: getInputShapeConstraints(methodName)");
+ }
+
+ std::lock_guard lock(self->mutex_);
+ if (!self->etModule_) {
+ throw jsi::JSError(rt, "getInputShapeConstraints: Model has been disposed");
+ }
+
+ auto methodName = conversions::asType(rt, "getInputShapeConstraints: methodName", args[0]);
+ auto methodMeta = unwrap(rt, "getInputShapeConstraints", self->etModule_->method_meta(methodName));
+
+ const auto it = self->inputShapeConstraints_.find(methodName);
+ const std::vector *constraints =
+ it != self->inputShapeConstraints_.end() ? &it->second : nullptr;
+
+ // Collect the tensor-input indices once (aligned with
+ // getMethodMeta().inputTensorMeta); input_tag is a lookup not worth
+ // repeating per input.
+ std::vector tensorInputs;
+ for (size_t i = 0; i < methodMeta.num_inputs(); ++i) {
+ auto ctx = std::format("getInputShapeConstraints('{}'): input tag [{}]", methodName, i);
+ if (unwrap(rt, ctx, methodMeta.input_tag(i)) == executorch::runtime::Tag::Tensor) {
+ tensorInputs.push_back(i);
+ }
+ }
+
+ auto jsArray = jsi::Array(rt, tensorInputs.size());
+ for (size_t outIndex = 0; outIndex < tensorInputs.size(); ++outIndex) {
+ const size_t i = tensorInputs[outIndex];
+ auto ctx = std::format("getInputShapeConstraints('{}'): input [{}]", methodName, i);
+ auto entry = jsi::Object(rt);
+ if (constraints != nullptr) {
+ encodeConstraint(rt, entry, (*constraints)[i]);
+ } else {
+ auto tensorMeta = unwrap(rt, ctx, methodMeta.input_tensor_meta(i));
+ auto shapeArr = jsi::Array(rt, tensorMeta.sizes().size());
+ for (size_t axis = 0; axis < tensorMeta.sizes().size(); ++axis) {
+ shapeArr.setValueAtIndex(rt, axis, static_cast(tensorMeta.sizes()[axis]));
+ }
+ entry.setProperty(rt, "kind", jsi::String::createFromUtf8(rt, "static"));
+ entry.setProperty(rt, "shape", shapeArr);
+ }
+ jsArray.setValueAtIndex(rt, outIndex, std::move(entry));
+ }
+ return jsArray;
+ };
+ return jsi::Function::createFromHostFunction(rt, jsi::PropNameID::forAscii(rt, "getInputShapeConstraints"), 1, fnBody);
+ }
+
if (nameStr == "execute") {
auto self = shared_from_this();
auto fnBody = [self](jsi::Runtime &rt, const jsi::Value & /*thisVal*/, const jsi::Value *args, size_t count) -> jsi::Value {
@@ -364,9 +602,12 @@ jsi::Value ModelHostObject::get(jsi::Runtime &rt, const jsi::PropNameID &name) {
auto expectedDtype = fromScalarType(rt, ctx, tensorMeta.scalar_type());
std::shared_ptr tensorHostObject;
- if (self->dynamicInputShapes_.contains(methodName)) {
- auto expectedShape = self->dynamicInputShapes_[methodName][i];
- tensorHostObject = tensor::fromJs(rt, ctx, val, expectedDtype, expectedShape);
+ if (self->inputShapeConstraints_.contains(methodName)) {
+ tensorHostObject = std::visit(
+ [&](const auto &constraint) {
+ return tensor::fromJs(rt, ctx, val, expectedDtype, constraint);
+ },
+ self->inputShapeConstraints_[methodName][i]);
} else {
tensorHostObject = tensor::fromJs(rt, ctx, val, expectedDtype, tensorMeta.sizes());
}
@@ -500,6 +741,7 @@ std::vector ModelHostObject::getPropertyNames(jsi::Ru
properties.push_back(jsi::PropNameID::forAscii(rt, "path"));
properties.push_back(jsi::PropNameID::forAscii(rt, "getMethodNames"));
properties.push_back(jsi::PropNameID::forAscii(rt, "getMethodMeta"));
+ properties.push_back(jsi::PropNameID::forAscii(rt, "getInputShapeConstraints"));
properties.push_back(jsi::PropNameID::forAscii(rt, "execute"));
properties.push_back(jsi::PropNameID::forAscii(rt, "dispose"));
return properties;
diff --git a/packages/react-native-executorch/cpp/core/model.h b/packages/react-native-executorch/cpp/core/model.h
index b196d48664..ab8fab3fa8 100644
--- a/packages/react-native-executorch/cpp/core/model.h
+++ b/packages/react-native-executorch/cpp/core/model.h
@@ -35,7 +35,10 @@ class ModelHostObject : public jsi::HostObject,
std::unique_ptr etModule_;
std::mutex mutex_;
- std::unordered_map> dynamicInputShapes_;
+ // Per-method, per-input shape constraints (dynamic ranges or enumerated
+ // shapes), parsed from the model's companion methods at load. Absent for
+ // methods whose inputs are fully static.
+ std::unordered_map> inputShapeConstraints_;
};
void install_loadModel(jsi::Runtime &rt, jsi::Object &module);
diff --git a/packages/react-native-executorch/cpp/core/tensor_helpers.cpp b/packages/react-native-executorch/cpp/core/tensor_helpers.cpp
index 54c9e23064..43d28e44a4 100644
--- a/packages/react-native-executorch/cpp/core/tensor_helpers.cpp
+++ b/packages/react-native-executorch/cpp/core/tensor_helpers.cpp
@@ -129,4 +129,53 @@ fromJs(jsi::Runtime &rt, const std::string &name, const jsi::Value &value,
return tensor;
}
+
+std::shared_ptr
+fromJs(jsi::Runtime &rt, const std::string &name, const jsi::Value &value,
+ std::optional expectedDtype, const EnumeratedShapes &enumeratedShapes) {
+
+ auto obj = conversions::asType(rt, name, value);
+ if (!obj.isHostObject(rt)) {
+ throw jsi::JSError(rt, name + " must be a Tensor");
+ }
+
+ auto tensor = obj.getHostObject(rt);
+ const auto &dtype = tensor->dtype_;
+ const auto &shape = tensor->shape_;
+
+ if (expectedDtype && dtype != *expectedDtype) {
+ throw jsi::JSError(rt, std::format("{} must be of type {}", name, types::toString(*expectedDtype)));
+ }
+
+ for (const auto &candidate : enumeratedShapes) {
+ if (candidate.size() != shape.size()) {
+ continue;
+ }
+ bool matches = true;
+ for (size_t axis = 0; axis < shape.size(); ++axis) {
+ if (candidate[axis] != shape[axis]) {
+ matches = false;
+ break;
+ }
+ }
+ if (matches) {
+ return tensor;
+ }
+ }
+
+ const auto join = [](const std::vector &dims) {
+ std::string s;
+ for (size_t axis = 0; axis < dims.size(); ++axis) {
+ s += (axis ? ", " : "");
+ s += std::to_string(dims[axis]);
+ }
+ return s;
+ };
+ std::string legal;
+ for (const auto &candidate : enumeratedShapes) {
+ legal += " [" + join(candidate) + "]";
+ }
+ throw jsi::JSError(rt, std::format("{} shape [{}] does not match any enumerated input shape; legal:{}",
+ name, join(shape), legal));
+}
} // namespace rnexecutorch::core::tensor
diff --git a/packages/react-native-executorch/cpp/core/tensor_helpers.h b/packages/react-native-executorch/cpp/core/tensor_helpers.h
index 9004e6f262..fee2ac0f0b 100644
--- a/packages/react-native-executorch/cpp/core/tensor_helpers.h
+++ b/packages/react-native-executorch/cpp/core/tensor_helpers.h
@@ -29,7 +29,26 @@ struct RangeDim {
std::optional step;
};
-using SymbolicShape = std::vector>;
+/** One axis of a {@link SymbolicShape}: a fixed size, a named symbol, or a {@link RangeDim}. */
+using SymbolicDim = std::variant;
+using SymbolicShape = std::vector;
+
+/**
+ * A set of complete legal input shapes for a tensor input, used by
+ * enumerated-shape backends (e.g. CoreML) that accept only specific whole
+ * shapes rather than a continuous range. Unlike RangeDim, which constrains a
+ * single dimension, an enumerated set is a cross-dimensional constraint: a
+ * concrete shape must equal one entry exactly.
+ */
+using EnumeratedShapes = std::vector>;
+
+/**
+ * The resolved shape constraint for a single tensor input: either dynamic
+ * per-dimension ranges ({@link SymbolicShape}) or an enumerated set of whole
+ * shapes ({@link EnumeratedShapes}). A method's inputs are uniformly one kind or
+ * the other — a single method cannot mix both.
+ */
+using ShapeConstraint = std::variant;
/**
* Tries to acquire a shared (read) lock on the underlying tensor resource.
@@ -88,6 +107,17 @@ std::shared_ptr
fromJs(jsi::Runtime &rt, const std::string &name, const jsi::Value &value,
std::optional expectedDtype, const std::optional &expectedShape);
+/**
+ * @overload
+ *
+ * Validates the tensor's concrete shape against an enumerated set: the shape
+ * must equal one of `enumeratedShapes` exactly (enumerated-shape backends).
+ * Throws a facebook::jsi::JSError listing the legal shapes on mismatch.
+ */
+std::shared_ptr
+fromJs(jsi::Runtime &rt, const std::string &name, const jsi::Value &value,
+ std::optional expectedDtype, const EnumeratedShapes &enumeratedShapes);
+
/**
* @overload
*
@@ -116,7 +146,7 @@ fromJs(jsi::Runtime &rt, const std::string &name, const jsi::Value &value,
inline std::shared_ptr
fromJs(jsi::Runtime &rt, const std::string &name, const jsi::Value &value,
std::optional expectedDtype,
- std::initializer_list> expectedShape) {
+ std::initializer_list expectedShape) {
return fromJs(rt, name, value, expectedDtype, std::optional(expectedShape));
}
} // namespace rnexecutorch::core::tensor
diff --git a/packages/react-native-executorch/cpp/extensions/cv/image_ops.cpp b/packages/react-native-executorch/cpp/extensions/cv/image_ops.cpp
index 25716c576c..7c7d6298d2 100644
--- a/packages/react-native-executorch/cpp/extensions/cv/image_ops.cpp
+++ b/packages/react-native-executorch/cpp/extensions/cv/image_ops.cpp
@@ -1,6 +1,7 @@
#include "image_ops.h"
#include
+#include
#include
#include
#include
@@ -432,4 +433,297 @@ void install_applyColormap(jsi::Runtime &rt, jsi::Object &module) {
};
module.setProperty(rt, name, jsi::Function::createFromHostFunction(rt, jsi::PropNameID::forAscii(rt, name), 3, fnBody));
}
+
+void install_rotate(jsi::Runtime &rt, jsi::Object &module) {
+ const auto *name = "rotate";
+ auto fnBody = [](jsi::Runtime &rt, const jsi::Value & /*thisVal*/, const jsi::Value *args, size_t count) -> jsi::Value {
+ if (count != 3) {
+ throw jsi::JSError(rt, "Usage: rotate(src, dst, degCW)");
+ }
+
+ auto src = tensor::fromJs(rt, "rotate: src", args[0], std::nullopt, {"H", "W", "C"});
+ auto dst = tensor::fromJs(rt, "rotate: dst", args[1], src->dtype_, {"H'", "W'", src->shape_[2]});
+ tensor::checkNotSameTensor(rt, "rotate: src", src, "rotate: dst", dst);
+
+ const auto degCW = conversions::asType(rt, "rotate: degCW", args[2]);
+ int rotateCode = 0;
+ if (degCW == 90) {
+ rotateCode = ::cv::ROTATE_90_CLOCKWISE;
+ } else if (degCW == 180) {
+ rotateCode = ::cv::ROTATE_180;
+ } else if (degCW == 270) {
+ rotateCode = ::cv::ROTATE_90_COUNTERCLOCKWISE;
+ } else {
+ throw jsi::JSError(rt, "rotate: degCW must be 90, 180, or 270");
+ }
+
+ const int32_t srcH = src->shape_[0];
+ const int32_t srcW = src->shape_[1];
+ const int32_t channels = src->shape_[2];
+ // 90/270 transpose the axes; 180 preserves them. dst must be pre-sized to match,
+ // else cv::rotate reallocates off the tensor buffer and the result is lost.
+ const bool swap = degCW != 180;
+ const int32_t expH = swap ? srcW : srcH;
+ const int32_t expW = swap ? srcH : srcW;
+ if (dst->shape_[0] != expH || dst->shape_[1] != expW) {
+ throw jsi::JSError(rt, "rotate: dst must be sized [" + std::to_string(expH) + ", " +
+ std::to_string(expW) + ", C] for a " + std::to_string(degCW) +
+ " degree rotation");
+ }
+
+ auto srcLock = tensor::tryLockShared(rt, "rotate: src", src);
+ auto dstLock = tensor::tryLockUnique(rt, "rotate: dst", dst);
+
+ int cvType{};
+ try {
+ cvType = CV_MAKETYPE(dtypeToCvDepth(src->dtype_), channels);
+ } catch (const std::exception &e) {
+ throw jsi::JSError(rt, "rotate: " + std::string(e.what()));
+ }
+
+ const ::cv::Mat srcMat(srcH, srcW, cvType, src->data_.get());
+ ::cv::Mat dstMat(expH, expW, cvType, dst->data_.get());
+ ::cv::rotate(srcMat, dstMat, rotateCode);
+
+ return jsi::Value(rt, args[1]);
+ };
+
+ module.setProperty(rt, name, jsi::Function::createFromHostFunction(rt, jsi::PropNameID::forAscii(rt, name), 3, fnBody));
+}
+
+// Warp `src` through a grid_sample-style backward field into `dst` via cv::remap.
+// grid is [..,2,gH,gW] in [-1,1] with align_corners=true (channel 0 = x, 1 = y).
+void install_warpByGrid(jsi::Runtime &rt, jsi::Object &module) {
+ const auto *name = "warpByGrid";
+ auto fnBody = [](jsi::Runtime &rt, const jsi::Value &, const jsi::Value *args, size_t count) -> jsi::Value {
+ if (count != 3) {
+ throw jsi::JSError(rt, "Usage: warpByGrid(src, grid, dst)");
+ }
+
+ using DType = rnexecutorch::core::types::DType;
+ auto src = tensor::fromJs(rt, "warpByGrid: src", args[0], DType::uint8, {"H", "W", "C"});
+ auto grid = tensor::fromJs(rt, "warpByGrid: grid", args[1], DType::float32, std::nullopt);
+ auto dst = tensor::fromJs(rt, "warpByGrid: dst", args[2], DType::uint8,
+ {src->shape_[0], src->shape_[1], src->shape_[2]});
+ tensor::checkNotSameTensor(rt, "warpByGrid: src", src, "warpByGrid: dst", dst);
+ tensor::checkNotSameTensor(rt, "warpByGrid: grid", grid, "warpByGrid: dst", dst);
+
+ // Grid rank varies, so fromJs can't constrain it; check the [..,2,gH,gW] tail here.
+ const auto &gs = grid->shape_;
+ if (gs.size() < 3 || gs[gs.size() - 3] != 2) {
+ throw jsi::JSError(rt, "warpByGrid: grid must be [..,2,gH,gW]");
+ }
+
+ auto srcLock = tensor::tryLockShared(rt, "warpByGrid: src", src);
+ auto gridLock = tensor::tryLockShared(rt, "warpByGrid: grid", grid);
+ auto dstLock = tensor::tryLockUnique(rt, "warpByGrid: dst", dst);
+
+ const int32_t h = src->shape_[0];
+ const int32_t w = src->shape_[1];
+ const int32_t channels = src->shape_[2];
+ const int32_t gridH = gs[gs.size() - 2];
+ const int32_t gridW = gs[gs.size() - 1];
+ const int32_t plane = gridH * gridW;
+ // Require exactly 2*gH*gW elements — [2,gH,gW], or an equivalent with
+ // batch dims of 1 (e.g. [1,2,gH,gW]). The sampler reads channels 0 and 1
+ // of a single plane, so a real batch > 1 would silently use batch 0 and a
+ // smaller buffer would read out of bounds.
+ size_t numel = 1;
+ for (const auto d : gs) {
+ numel *= static_cast(d);
+ }
+ if (numel != static_cast(2) * static_cast(plane)) {
+ throw jsi::JSError(rt, "warpByGrid: grid must have exactly 2*gH*gW elements ([2,gH,gW], batch > 1 not supported)");
+ }
+ const auto *g = reinterpret_cast(grid->data_.get());
+
+ // Bilinearly sample channel `c` of the low-res grid at fractional (gx, gy).
+ auto sampleGrid = [&](int32_t c, float gx, float gy) -> float {
+ const int32_t x0 = std::clamp(static_cast(std::floor(gx)), 0, gridW - 1);
+ const int32_t y0 = std::clamp(static_cast(std::floor(gy)), 0, gridH - 1);
+ const int32_t x1 = std::min(x0 + 1, gridW - 1);
+ const int32_t y1 = std::min(y0 + 1, gridH - 1);
+ const float dx = gx - static_cast(x0);
+ const float dy = gy - static_cast(y0);
+ const int32_t base = c * plane;
+ const float top = g[base + y0 * gridW + x0] +
+ (g[base + y0 * gridW + x1] - g[base + y0 * gridW + x0]) * dx;
+ const float bot = g[base + y1 * gridW + x0] +
+ (g[base + y1 * gridW + x1] - g[base + y1 * gridW + x0]) * dx;
+ return top + (bot - top) * dy;
+ };
+
+ ::cv::Mat mapX(h, w, CV_32F);
+ ::cv::Mat mapY(h, w, CV_32F);
+ for (int32_t oy = 0; oy < h; ++oy) {
+ const float gy = h > 1 ? (static_cast(oy) / static_cast(h - 1)) *
+ static_cast(gridH - 1)
+ : 0.0f;
+ auto *rowX = mapX.ptr(oy);
+ auto *rowY = mapY.ptr(oy);
+ for (int32_t ox = 0; ox < w; ++ox) {
+ const float gx = w > 1 ? (static_cast(ox) / static_cast(w - 1)) *
+ static_cast(gridW - 1)
+ : 0.0f;
+ const float nx = sampleGrid(0, gx, gy); // [-1,1]
+ const float ny = sampleGrid(1, gx, gy);
+ rowX[ox] = ((nx + 1.0f) / 2.0f) * static_cast(w - 1);
+ rowY[ox] = ((ny + 1.0f) / 2.0f) * static_cast(h - 1);
+ }
+ }
+
+ const int cvType = CV_MAKETYPE(CV_8U, channels);
+ ::cv::Mat srcMat(h, w, cvType, src->data_.get());
+ ::cv::Mat dstMat(h, w, cvType, dst->data_.get());
+ try {
+ ::cv::remap(srcMat, dstMat, mapX, mapY, ::cv::INTER_LINEAR, ::cv::BORDER_REPLICATE);
+ } catch (const std::exception &e) {
+ throw jsi::JSError(rt, std::string("warpByGrid: OpenCV error: ") + e.what());
+ }
+ return jsi::Value(rt, args[2]);
+ };
+ module.setProperty(rt, name, jsi::Function::createFromHostFunction(rt, jsi::PropNameID::forAscii(rt, name), 3, fnBody));
+}
+
+// Copy an axis-aligned [x0,y0,x1,y1] region of `src` into the pre-sized `dst` — a
+// shape-changing crop via a native cv::Mat ROI copy (unlike restrictToBox, which
+// masks in place at the same size). `dst` must be sized [y1-y0, x1-x0, C].
+void install_crop(jsi::Runtime &rt, jsi::Object &module) {
+ const auto *name = "crop";
+ auto fnBody = [](jsi::Runtime &rt, const jsi::Value & /*thisVal*/, const jsi::Value *args,
+ size_t count) -> jsi::Value {
+ if (count != 3) {
+ throw jsi::JSError(rt, "Usage: crop(src, dst, [x0, y0, x1, y1])");
+ }
+ auto src = tensor::fromJs(rt, "crop: src", args[0], std::nullopt, {"H", "W", "C"});
+ auto dst = tensor::fromJs(rt, "crop: dst", args[1], src->dtype_, {"H'", "W'", src->shape_[2]});
+ tensor::checkNotSameTensor(rt, "crop: src", src, "crop: dst", dst);
+
+ const auto boxArr = conversions::asType(rt, "crop: box", args[2]);
+ if (boxArr.length(rt) != 4) {
+ throw jsi::JSError(rt, "crop: box must be [x0, y0, x1, y1]");
+ }
+ const int32_t srcH = src->shape_[0];
+ const int32_t srcW = src->shape_[1];
+ const int32_t channels = src->shape_[2];
+ const int32_t x0 = std::clamp(conversions::asType(rt, "crop: box", boxArr.getValueAtIndex(rt, 0)), 0, srcW);
+ const int32_t y0 = std::clamp(conversions::asType(rt, "crop: box", boxArr.getValueAtIndex(rt, 1)), 0, srcH);
+ const int32_t x1 = std::clamp(conversions::asType(rt, "crop: box", boxArr.getValueAtIndex(rt, 2)), 0, srcW);
+ const int32_t y1 = std::clamp(conversions::asType(rt, "crop: box", boxArr.getValueAtIndex(rt, 3)), 0, srcH);
+ const int32_t cropW = x1 - x0;
+ const int32_t cropH = y1 - y0;
+ if (cropW <= 0 || cropH <= 0) {
+ throw jsi::JSError(rt, "crop: box does not intersect the image");
+ }
+ if (dst->shape_[0] != cropH || dst->shape_[1] != cropW) {
+ throw jsi::JSError(rt, "crop: dst must be sized [" + std::to_string(cropH) + ", " +
+ std::to_string(cropW) + ", C] for the clamped box");
+ }
+
+ auto srcLock = tensor::tryLockShared(rt, "crop: src", src);
+ auto dstLock = tensor::tryLockUnique(rt, "crop: dst", dst);
+
+ int cvType{};
+ try {
+ cvType = CV_MAKETYPE(dtypeToCvDepth(src->dtype_), channels);
+ } catch (const std::exception &e) {
+ throw jsi::JSError(rt, "crop: " + std::string(e.what()));
+ }
+
+ const ::cv::Mat srcMat(srcH, srcW, cvType, src->data_.get());
+ ::cv::Mat dstMat(cropH, cropW, cvType, dst->data_.get());
+ srcMat(::cv::Rect(x0, y0, cropW, cropH)).copyTo(dstMat);
+
+ return jsi::Value(rt, args[1]);
+ };
+ module.setProperty(rt, name, jsi::Function::createFromHostFunction(rt, jsi::PropNameID::forAscii(rt, name), 3, fnBody));
+}
+
+// Perspective-crop an oriented quad of `src` into the `dst` canvas (crop +
+// resize-to-height + pad/align) — normalizes a detected text box for the recognizer.
+void install_rectifyQuad(jsi::Runtime &rt, jsi::Object &module) {
+ const auto *name = "rectifyQuad";
+ auto fnBody = [](jsi::Runtime &rt, const jsi::Value &, const jsi::Value *args,
+ size_t count) -> jsi::Value {
+ if (count != 4) {
+ throw jsi::JSError(rt, "Usage: rectifyQuad(src, dst, quad, options)");
+ }
+ using DType = rnexecutorch::core::types::DType;
+ auto src = tensor::fromJs(rt, "rectifyQuad: src", args[0], DType::uint8, {"H", "W", "C"});
+ auto dst = tensor::fromJs(rt, "rectifyQuad: dst", args[1], DType::uint8,
+ {"H'", "W'", src->shape_[2]});
+ tensor::checkNotSameTensor(rt, "rectifyQuad: src", src, "rectifyQuad: dst", dst);
+ const auto quadArr = conversions::asType(rt, "rectifyQuad: quad", args[2]);
+ const auto opts = conversions::asType(rt, "rectifyQuad: options", args[3]);
+ if (quadArr.length(rt) != 8) {
+ throw jsi::JSError(rt, "rectifyQuad: quad must have exactly 8 numbers (4 points)");
+ }
+
+ const int32_t channels = src->shape_[2];
+ const int32_t recH = dst->shape_[0];
+ const int32_t canvasW = dst->shape_[1];
+
+ const int32_t contentWidth = std::clamp(
+ conversions::getRequiredProperty(rt, "rectifyQuad: options", opts, "contentWidth"),
+ 1, canvasW);
+ const auto padMode = conversions::getRequiredProperty(rt, "rectifyQuad: options", opts, "padMode");
+ const auto padValue = conversions::getRequiredProperty(rt, "rectifyQuad: options", opts, "padValue");
+ const auto align = conversions::getRequiredProperty(rt, "rectifyQuad: options", opts, "align");
+
+ std::array<::cv::Point2f, 4> quad;
+ for (std::size_t i = 0; i < 4; ++i) {
+ quad[i] = {conversions::asType(rt, "rectifyQuad: quad", quadArr.getValueAtIndex(rt, i * 2)),
+ conversions::asType(rt, "rectifyQuad: quad", quadArr.getValueAtIndex(rt, i * 2 + 1))};
+ }
+
+ auto srcLock = tensor::tryLockShared(rt, "rectifyQuad: src", src);
+ auto dstLock = tensor::tryLockUnique(rt, "rectifyQuad: dst", dst);
+
+ const int cvType = CV_MAKETYPE(CV_8U, channels);
+ ::cv::Mat srcMat(src->shape_[0], src->shape_[1], cvType, src->data_.get());
+ ::cv::Mat dstMat(recH, canvasW, cvType, dst->data_.get());
+
+ try {
+ const std::array<::cv::Point2f, 4> dstPts = {
+ ::cv::Point2f{0.0f, 0.0f},
+ {static_cast(contentWidth), 0.0f},
+ {static_cast(contentWidth), static_cast(recH)},
+ {0.0f, static_cast(recH)}};
+ const std::array<::cv::Point2f, 4> srcPts = {quad[0], quad[1], quad[2], quad[3]};
+ ::cv::Mat m = ::cv::getPerspectiveTransform(srcPts.data(), dstPts.data());
+ ::cv::Mat content;
+ ::cv::warpPerspective(srcMat, content, m, ::cv::Size(contentWidth, recH),
+ ::cv::INTER_CUBIC, ::cv::BORDER_REPLICATE);
+
+ ::cv::Scalar padColor;
+ if (padMode == "cornerMean") {
+ const int patch = std::max(1, std::min(recH, contentWidth) / 30);
+ ::cv::Scalar acc(0, 0, 0, 0);
+ const std::array<::cv::Rect, 4> rects = {
+ ::cv::Rect(0, 0, patch, patch),
+ ::cv::Rect(contentWidth - patch, 0, patch, patch),
+ ::cv::Rect(0, recH - patch, patch, patch),
+ ::cv::Rect(contentWidth - patch, recH - patch, patch, patch)};
+ for (const auto &r : rects) {
+ acc += ::cv::mean(content(r));
+ }
+ padColor = acc / 4.0;
+ } else {
+ padColor = ::cv::Scalar::all(padValue);
+ }
+
+ dstMat.setTo(padColor);
+ const int32_t offsetX = (align == "center") ? (canvasW - contentWidth) / 2 : 0;
+ const int32_t copyW = std::min(contentWidth, canvasW - offsetX);
+ content(::cv::Rect(0, 0, copyW, recH)).copyTo(dstMat(::cv::Rect(offsetX, 0, copyW, recH)));
+ } catch (const std::exception &e) {
+ throw jsi::JSError(rt, std::string("rectifyQuad: OpenCV error: ") + e.what());
+ }
+ return jsi::Value(rt, args[1]);
+ };
+ module.setProperty(rt, name,
+ jsi::Function::createFromHostFunction(rt, jsi::PropNameID::forAscii(rt, name),
+ 4, fnBody));
+}
+
} // namespace rnexecutorch::extensions::cv::image_ops
diff --git a/packages/react-native-executorch/cpp/extensions/cv/image_ops.h b/packages/react-native-executorch/cpp/extensions/cv/image_ops.h
index 893c0957e5..1ba300aebe 100644
--- a/packages/react-native-executorch/cpp/extensions/cv/image_ops.h
+++ b/packages/react-native-executorch/cpp/extensions/cv/image_ops.h
@@ -9,4 +9,8 @@ void install_toChannelsFirst(facebook::jsi::Runtime &rt, facebook::jsi::Object &
void install_toChannelsLast(facebook::jsi::Runtime &rt, facebook::jsi::Object &module);
void install_normalize(facebook::jsi::Runtime &rt, facebook::jsi::Object &module);
void install_applyColormap(facebook::jsi::Runtime &rt, facebook::jsi::Object &module);
+void install_rotate(facebook::jsi::Runtime &rt, facebook::jsi::Object &module);
+void install_crop(facebook::jsi::Runtime &rt, facebook::jsi::Object &module);
+void install_warpByGrid(facebook::jsi::Runtime &rt, facebook::jsi::Object &module);
+void install_rectifyQuad(facebook::jsi::Runtime &rt, facebook::jsi::Object &module);
} // namespace rnexecutorch::extensions::cv::image_ops
diff --git a/packages/react-native-executorch/cpp/extensions/cv/install.cpp b/packages/react-native-executorch/cpp/extensions/cv/install.cpp
index 0540f45c9b..0388a15395 100644
--- a/packages/react-native-executorch/cpp/extensions/cv/install.cpp
+++ b/packages/react-native-executorch/cpp/extensions/cv/install.cpp
@@ -1,6 +1,7 @@
#include "install.h"
#include "box_ops.h"
#include "image_ops.h"
+#include "ocr_ops.h"
namespace rnexecutorch::extensions::cv {
namespace jsi = facebook::jsi;
@@ -14,10 +15,18 @@ void install(facebook::jsi::Runtime &rt, facebook::jsi::Object &module) {
image_ops::install_toChannelsLast(rt, cvModule);
image_ops::install_normalize(rt, cvModule);
image_ops::install_applyColormap(rt, cvModule);
+ image_ops::install_rotate(rt, cvModule);
+ image_ops::install_crop(rt, cvModule);
+ image_ops::install_warpByGrid(rt, cvModule);
+ image_ops::install_rectifyQuad(rt, cvModule);
box_ops::install_nms(rt, cvModule);
box_ops::install_restrictToBox(rt, cvModule);
+ ocr_ops::install_extractCraftTextBoxes(rt, cvModule);
+ ocr_ops::install_extractDbnetTextBoxes(rt, cvModule);
+ ocr_ops::install_ctcGreedyDecode(rt, cvModule);
+
module.setProperty(rt, "cv", cvModule);
}
} // namespace rnexecutorch::extensions::cv
diff --git a/packages/react-native-executorch/cpp/extensions/cv/ocr_ops.cpp b/packages/react-native-executorch/cpp/extensions/cv/ocr_ops.cpp
new file mode 100644
index 0000000000..f172128ab5
--- /dev/null
+++ b/packages/react-native-executorch/cpp/extensions/cv/ocr_ops.cpp
@@ -0,0 +1,398 @@
+#include "ocr_ops.h"
+
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+
+#include
+
+#include "core/conversions.h"
+#include "core/dtype.h"
+#include "core/tensor.h"
+#include "core/tensor_helpers.h"
+
+namespace rnexecutorch::extensions::cv::ocr_ops {
+namespace jsi = facebook::jsi;
+namespace tensor = rnexecutorch::core::tensor;
+namespace conversions = rnexecutorch::core::conversions;
+using DType = rnexecutorch::core::types::DType;
+
+namespace {
+// Drop CRAFT connected components smaller than this (detector-input px²).
+constexpr int32_t kMinComponentArea = 10;
+
+// Axis-aligned box (p0 = min, p1 = max) plus the rotated-rect angle. Line grouping
+// and de-skew live in TypeScript; native only produces these component boxes.
+struct Box {
+ float x0{}, y0{}, x1{}, y1{};
+ float angle = 0.0f;
+};
+
+using Quad = std::array<::cv::Point2f, 4>;
+
+// ------------------------------ CRAFT branch -------------------------------
+// All per-component work stays inside the component's (dilation-padded) rect —
+// full-frame masks per component would make the decode O(components · H · W).
+std::optional boxFromComponent(const ::cv::Mat &textMap, const ::cv::Mat &labels,
+ const ::cv::Mat &stats, int32_t i, int32_t imgW, int32_t imgH,
+ float lowTextThreshold) {
+ const int32_t area = stats.at(i, ::cv::CC_STAT_AREA);
+ if (area < kMinComponentArea) {
+ return std::nullopt;
+ }
+ const int32_t x = stats.at(i, ::cv::CC_STAT_LEFT);
+ const int32_t y = stats.at(i, ::cv::CC_STAT_TOP);
+ const int32_t w = stats.at(i, ::cv::CC_STAT_WIDTH);
+ const int32_t h = stats.at(i, ::cv::CC_STAT_HEIGHT);
+ const ::cv::Rect compRect(x, y, w, h);
+ ::cv::Mat mask = (labels(compRect) == i);
+ double maxVal = 0.0;
+ ::cv::minMaxLoc(textMap(compRect), nullptr, &maxVal, nullptr, nullptr, mask);
+ if (maxVal < static_cast(lowTextThreshold)) {
+ return std::nullopt;
+ }
+
+ const auto dilationRadius =
+ static_cast(std::sqrt(static_cast(area) / std::max(w, h)) * 2);
+ const int32_t sx = std::max(x - dilationRadius, 0);
+ const int32_t ex = std::min(x + w + dilationRadius, imgW);
+ const int32_t sy = std::max(y - dilationRadius, 0);
+ const int32_t ey = std::min(y + h + dilationRadius, imgH);
+ ::cv::Mat segMap = ::cv::Mat::zeros(ey - sy, ex - sx, CV_8U);
+ segMap(::cv::Rect(x - sx, y - sy, w, h)).setTo(255, mask);
+ const int32_t kSize = 1 + dilationRadius;
+ ::cv::Mat kernel = ::cv::getStructuringElement(::cv::MORPH_RECT, ::cv::Size(kSize, kSize));
+ ::cv::dilate(segMap, segMap, kernel, ::cv::Point(-1, -1), 1);
+
+ std::vector> contours;
+ // The offset restores full-frame coordinates for the ROI-local contours.
+ ::cv::findContours(segMap, contours, ::cv::RETR_EXTERNAL, ::cv::CHAIN_APPROX_SIMPLE,
+ ::cv::Point(sx, sy));
+ if (contours.empty()) {
+ return std::nullopt;
+ }
+ ::cv::RotatedRect rr = ::cv::minAreaRect(contours[0]);
+ std::array<::cv::Point2f, 4> v;
+ rr.points(v.data());
+ Box box;
+ box.x0 = std::min({v[0].x, v[1].x, v[2].x, v[3].x});
+ box.y0 = std::min({v[0].y, v[1].y, v[2].y, v[3].y});
+ box.x1 = std::max({v[0].x, v[1].x, v[2].x, v[3].x});
+ box.y1 = std::max({v[0].y, v[1].y, v[2].y, v[3].y});
+ box.angle = rr.angle;
+ return box;
+}
+
+// CRAFT text+affinity maps -> one component box each. charLevel=false ADDS
+// affinity to link adjacent glyphs into line-regions (keeping the rotated-rect
+// angle); charLevel=true SUBTRACTS it (+ erode/dilate) to break them into upright
+// per-glyph boxes (angle 0) for the stacked-column reader.
+std::vector componentBoxes(::cv::Mat &textMap, ::cv::Mat &affinityMap, float textThreshold,
+ float linkThreshold, float lowTextThreshold, bool charLevel) {
+ const int32_t imgH = textMap.rows;
+ const int32_t imgW = textMap.cols;
+ ::cv::Mat textScore;
+ ::cv::Mat affinityScore;
+ ::cv::threshold(textMap, textScore, static_cast(textThreshold), 1.0, ::cv::THRESH_BINARY);
+ ::cv::threshold(affinityMap, affinityScore, static_cast(linkThreshold), 1.0,
+ ::cv::THRESH_BINARY);
+
+ ::cv::Mat comb;
+ if (charLevel) {
+ comb = textScore - affinityScore; // subtract to separate adjacent glyphs
+ ::cv::threshold(comb, comb, 0.0, 1.0, ::cv::THRESH_TOZERO);
+ ::cv::threshold(comb, comb, 1.0, 1.0, ::cv::THRESH_TRUNC);
+ ::cv::Mat kernel = ::cv::getStructuringElement(::cv::MORPH_RECT, ::cv::Size(3, 3));
+ ::cv::erode(comb, comb, kernel, ::cv::Point(-1, -1), 1);
+ ::cv::dilate(comb, comb, kernel, ::cv::Point(-1, -1), 4);
+ } else {
+ comb = textScore + affinityScore; // add to link adjacent glyphs into lines
+ ::cv::threshold(comb, comb, 0.0, 1.0, ::cv::THRESH_BINARY);
+ }
+
+ ::cv::Mat binary;
+ comb.convertTo(binary, CV_8UC1);
+ ::cv::Mat labels;
+ ::cv::Mat stats;
+ ::cv::Mat centroids;
+ const int32_t nLabels = ::cv::connectedComponentsWithStats(binary, labels, stats, centroids, 4);
+
+ std::vector boxes;
+ boxes.reserve(static_cast(nLabels));
+ for (int32_t i = 1; i < nLabels; ++i) {
+ auto box = boxFromComponent(textMap, labels, stats, i, imgW, imgH, lowTextThreshold);
+ if (box) {
+ if (charLevel) {
+ box->angle = 0.0f; // glyphs are read upright, never rotated
+ }
+ boxes.push_back(*box);
+ }
+ }
+ return boxes;
+}
+
+// CRAFT half-res heatmap (text+affinity interleaved) -> component boxes in
+// detector-input pixels; restoreRatio scales the half-res boxes back up. Line
+// grouping and de-skew are done in TypeScript; charLevel yields upright per-glyph
+// boxes. `data` points at heatW*heatH*2 floats.
+std::vector extractCraft(float *data, int32_t heatW, int32_t heatH, float textThreshold,
+ float linkThreshold, float lowTextThreshold, float restoreRatio,
+ bool charLevel) {
+ // Deinterleave the [text, affinity] channels of the half-res heatmap.
+ ::cv::Mat interleaved(heatH, heatW, CV_32FC2, data);
+ std::array<::cv::Mat, 2> channels;
+ ::cv::split(interleaved, channels);
+ std::vector boxes = componentBoxes(channels[0], channels[1], textThreshold, linkThreshold,
+ lowTextThreshold, charLevel);
+ for (auto &b : boxes) {
+ b.x0 *= restoreRatio;
+ b.y0 *= restoreRatio;
+ b.x1 *= restoreRatio;
+ b.y1 *= restoreRatio;
+ }
+ return boxes;
+}
+
+// ------------------------------ DBNet branch -------------------------------
+// DBNet prob map [H,W] -> oriented quads. The map must be post-sigmoid
+// probabilities — any activation is baked into the model's export.
+std::vector extractDbnet(const ::cv::Mat &prob, float binThreshold, float boxThreshold,
+ float unclipRatio, int32_t minBoxSide, int32_t maxCandidates) {
+ const int32_t w = prob.cols;
+ const int32_t h = prob.rows;
+
+ ::cv::Mat bitmap;
+ ::cv::threshold(prob, bitmap, static_cast(binThreshold), 255, ::cv::THRESH_BINARY);
+ bitmap.convertTo(bitmap, CV_8UC1);
+
+ std::vector> contours;
+ ::cv::findContours(bitmap, contours, ::cv::RETR_LIST, ::cv::CHAIN_APPROX_SIMPLE);
+
+ std::vector quads;
+ const int32_t maxN = static_cast(
+ std::min(contours.size(), static_cast(maxCandidates)));
+ for (int32_t i = 0; i < maxN; ++i) {
+ const auto &contour = contours[static_cast(i)];
+ if (contour.size() < 4) {
+ continue;
+ }
+ ::cv::RotatedRect rr = ::cv::minAreaRect(contour);
+ if (std::min(rr.size.width, rr.size.height) < static_cast(minBoxSide)) {
+ continue;
+ }
+ // Score inside the contour's bounding rect only — a full-frame mask per
+ // candidate would make scoring O(candidates · H · W).
+ const ::cv::Rect bounds = ::cv::boundingRect(contour);
+ ::cv::Mat mask = ::cv::Mat::zeros(bounds.size(), CV_8UC1);
+ ::cv::drawContours(mask, contours, i, ::cv::Scalar(255), ::cv::FILLED, ::cv::LINE_8,
+ ::cv::noArray(), 0, -bounds.tl());
+ const float score = static_cast(::cv::mean(prob(bounds), mask)[0]);
+ if (score < boxThreshold) {
+ continue;
+ }
+ const double area = static_cast(rr.size.width) * static_cast(rr.size.height);
+ const double perim =
+ 2.0 * (static_cast(rr.size.width) + static_cast(rr.size.height));
+ const double distance = perim > 0.0 ? area * static_cast(unclipRatio) / perim : 0.0;
+ const auto grow = static_cast(2.0 * distance);
+ ::cv::RotatedRect expanded(rr.center,
+ ::cv::Size2f(rr.size.width + grow, rr.size.height + grow),
+ rr.angle);
+ if (std::min(expanded.size.width, expanded.size.height) <
+ static_cast(minBoxSide + 2)) {
+ continue;
+ }
+ std::array<::cv::Point2f, 4> c;
+ expanded.points(c.data());
+ Quad q;
+ auto minX = static_cast(w);
+ auto minY = static_cast(h);
+ float maxX = 0;
+ float maxY = 0;
+ for (int32_t k = 0; k < 4; ++k) {
+ // Clamp to the last valid pixel index (w-1/h-1), not w/h — a corner at
+ // exactly w or h is one past the last column/row.
+ const float px = std::clamp(c[static_cast(k)].x, 0.0f, static_cast(w - 1));
+ const float py = std::clamp(c[static_cast(k)].y, 0.0f, static_cast(h - 1));
+ q[static_cast(k)] = {px, py};
+ minX = std::min(minX, px);
+ minY = std::min(minY, py);
+ maxX = std::max(maxX, px);
+ maxY = std::max(maxY, py);
+ }
+ if (maxX - minX < 1.0f || maxY - minY < 1.0f) {
+ continue;
+ }
+ quads.push_back(q);
+ }
+ // Output order is unspecified — the TypeScript pipeline derives reading
+ // order geometrically for every result set.
+ return quads;
+}
+
+// Flatten quads to a JS double array, 8 per box (x0,y0..x3,y3).
+jsi::Array quadsToArray(jsi::Runtime &rt, const std::vector &quads) {
+ jsi::Array out(rt, quads.size() * 8);
+ size_t idx = 0;
+ for (const auto &q : quads) {
+ for (std::size_t k = 0; k < 4; ++k) {
+ out.setValueAtIndex(rt, idx++, jsi::Value(static_cast(q[k].x)));
+ out.setValueAtIndex(rt, idx++, jsi::Value(static_cast(q[k].y)));
+ }
+ }
+ return out;
+}
+
+// Flatten component boxes to a JS double array, 5 per box (x0,y0,x1,y1,angle).
+jsi::Array boxesToArray(jsi::Runtime &rt, const std::vector &boxes) {
+ jsi::Array out(rt, boxes.size() * 5);
+ size_t idx = 0;
+ for (const auto &b : boxes) {
+ out.setValueAtIndex(rt, idx++, jsi::Value(static_cast(b.x0)));
+ out.setValueAtIndex(rt, idx++, jsi::Value(static_cast(b.y0)));
+ out.setValueAtIndex(rt, idx++, jsi::Value(static_cast(b.x1)));
+ out.setValueAtIndex(rt, idx++, jsi::Value(static_cast(b.y1)));
+ out.setValueAtIndex(rt, idx++, jsi::Value(static_cast(b.angle)));
+ }
+ return out;
+}
+
+} // namespace
+
+void install_extractCraftTextBoxes(jsi::Runtime &rt, jsi::Object &module) {
+ const auto *name = "extractCraftTextBoxes";
+ auto fnBody = [](jsi::Runtime &rt, const jsi::Value &, const jsi::Value *args,
+ size_t count) -> jsi::Value {
+ if (count != 2) {
+ throw jsi::JSError(rt, "Usage: extractCraftTextBoxes(src, options)");
+ }
+ const char *ctx = "extractCraftTextBoxes";
+ auto src = tensor::fromJs(rt, "extractCraftTextBoxes: src", args[0], DType::float32, std::nullopt);
+ const auto opts = conversions::asType(rt, "extractCraftTextBoxes: options", args[1]);
+ auto srcLock = tensor::tryLockShared(rt, "extractCraftTextBoxes: src", src);
+ auto *dataPtr = reinterpret_cast(src->data_.get());
+
+ // src is [1,Hd,Wd,2] or [Hd,Wd,2] interleaved (text, affinity), half-res.
+ const auto &s = src->shape_;
+ if (s.size() < 3 || s.back() != 2) {
+ throw jsi::JSError(rt, "extractCraftTextBoxes: src must be [..,Hd,Wd,2]");
+ }
+ const int32_t heatW = s[s.size() - 2];
+ const int32_t heatH = s[s.size() - 3];
+ if (static_cast(heatW) * static_cast(heatH) * 2 != src->numel_) {
+ throw jsi::JSError(rt, "extractCraftTextBoxes: src Hd*Wd*2 does not match numel");
+ }
+ const auto targetH = conversions::getRequiredProperty(rt, ctx, opts, "targetHeight");
+ const float restoreRatio = static_cast(targetH) / static_cast(heatH);
+ const bool charLevel = conversions::getRequiredProperty(rt, ctx, opts, "charLevel");
+
+ std::vector boxes;
+ try {
+ boxes = extractCraft(
+ dataPtr, heatW, heatH,
+ static_cast(conversions::getRequiredProperty(rt, ctx, opts, "textThreshold")),
+ static_cast(conversions::getRequiredProperty(rt, ctx, opts, "linkThreshold")),
+ static_cast(conversions::getRequiredProperty(rt, ctx, opts, "lowTextThreshold")),
+ restoreRatio, charLevel);
+ } catch (const std::exception &e) {
+ throw jsi::JSError(rt, std::string("extractCraftTextBoxes: OpenCV error: ") + e.what());
+ }
+ return boxesToArray(rt, boxes);
+ };
+ module.setProperty(rt, name,
+ jsi::Function::createFromHostFunction(rt, jsi::PropNameID::forAscii(rt, name),
+ 2, fnBody));
+}
+
+void install_extractDbnetTextBoxes(jsi::Runtime &rt, jsi::Object &module) {
+ const auto *name = "extractDbnetTextBoxes";
+ auto fnBody = [](jsi::Runtime &rt, const jsi::Value &, const jsi::Value *args,
+ size_t count) -> jsi::Value {
+ if (count != 2) {
+ throw jsi::JSError(rt, "Usage: extractDbnetTextBoxes(src, options)");
+ }
+ const char *ctx = "extractDbnetTextBoxes";
+ auto src = tensor::fromJs(rt, "extractDbnetTextBoxes: src", args[0], DType::float32, std::nullopt);
+ const auto opts = conversions::asType(rt, "extractDbnetTextBoxes: options", args[1]);
+ auto srcLock = tensor::tryLockShared(rt, "extractDbnetTextBoxes: src", src);
+ auto *dataPtr = reinterpret_cast(src->data_.get());
+
+ // src is [1,1,H,W] or [H,W] probability map (full-res).
+ const auto &s = src->shape_;
+ if (s.size() < 2) {
+ throw jsi::JSError(rt, "extractDbnetTextBoxes: src must be [..,H,W]");
+ }
+ const int32_t w = s[s.size() - 1];
+ const int32_t h = s[s.size() - 2];
+ if (static_cast(w) * static_cast(h) != src->numel_) {
+ throw jsi::JSError(rt, "extractDbnetTextBoxes: src H*W does not match numel");
+ }
+
+ std::vector quads;
+ try {
+ ::cv::Mat prob(h, w, CV_32F, dataPtr);
+ quads = extractDbnet(
+ prob, static_cast(conversions::getRequiredProperty(rt, ctx, opts, "binThreshold")),
+ static_cast(conversions::getRequiredProperty(rt, ctx, opts, "boxThreshold")),
+ static_cast(conversions::getRequiredProperty(rt, ctx, opts, "unclipRatio")),
+ conversions::getRequiredProperty(rt, ctx, opts, "minBoxSide"),
+ conversions::getRequiredProperty(rt, ctx, opts, "maxCandidates"));
+ } catch (const std::exception &e) {
+ throw jsi::JSError(rt, std::string("extractDbnetTextBoxes: OpenCV error: ") + e.what());
+ }
+ return quadsToArray(rt, quads);
+ };
+ module.setProperty(rt, name,
+ jsi::Function::createFromHostFunction(rt, jsi::PropNameID::forAscii(rt, name),
+ 2, fnBody));
+}
+
+// --------------------------- ctcGreedyDecode -------------------------------
+// Per-timestep argmax + max value over [..,T,V] logits. `values` are the raw
+// max activations; if a caller needs probabilities it softmaxes the tensor (via
+// the math.softmax op) before decoding — this op takes no options.
+void install_ctcGreedyDecode(jsi::Runtime &rt, jsi::Object &module) {
+ const auto *name = "ctcGreedyDecode";
+ auto fnBody = [](jsi::Runtime &rt, const jsi::Value &, const jsi::Value *args,
+ size_t count) -> jsi::Value {
+ if (count != 1) {
+ throw jsi::JSError(rt, "Usage: ctcGreedyDecode(src)");
+ }
+ auto src = tensor::fromJs(rt, "ctcGreedyDecode: src", args[0], DType::float32, std::nullopt);
+ auto srcLock = tensor::tryLockShared(rt, "ctcGreedyDecode: src", src);
+
+ const auto &s = src->shape_;
+ if (s.size() < 2) {
+ throw jsi::JSError(rt, "ctcGreedyDecode: src must be at least 2-D [..,T,V]");
+ }
+ const int32_t vocab = s.back();
+ if (vocab < 1) {
+ throw jsi::JSError(rt, "ctcGreedyDecode: vocab dimension must be >= 1");
+ }
+ if (src->numel_ % static_cast(vocab) != 0) {
+ throw jsi::JSError(rt, "ctcGreedyDecode: numel must be a multiple of the vocab dim");
+ }
+ const int32_t timesteps = static_cast(src->numel_) / vocab;
+ const auto *data = reinterpret_cast(src->data_.get());
+
+ jsi::Array out(rt, static_cast(timesteps) * 2);
+ size_t oi = 0;
+ for (int32_t t = 0; t < timesteps; ++t) {
+ const float *row = data + static_cast(t) * static_cast(vocab);
+ const float *maxIt = std::max_element(row, row + vocab);
+ const auto maxIdx = static_cast(maxIt - row);
+ out.setValueAtIndex(rt, oi++, jsi::Value(static_cast(maxIdx)));
+ out.setValueAtIndex(rt, oi++, jsi::Value(static_cast(*maxIt)));
+ }
+ return out;
+ };
+ module.setProperty(rt, name,
+ jsi::Function::createFromHostFunction(rt, jsi::PropNameID::forAscii(rt, name),
+ 1, fnBody));
+}
+
+} // namespace rnexecutorch::extensions::cv::ocr_ops
diff --git a/packages/react-native-executorch/cpp/extensions/cv/ocr_ops.h b/packages/react-native-executorch/cpp/extensions/cv/ocr_ops.h
new file mode 100644
index 0000000000..1e47193bb0
--- /dev/null
+++ b/packages/react-native-executorch/cpp/extensions/cv/ocr_ops.h
@@ -0,0 +1,9 @@
+#pragma once
+
+#include
+
+namespace rnexecutorch::extensions::cv::ocr_ops {
+void install_extractCraftTextBoxes(facebook::jsi::Runtime &rt, facebook::jsi::Object &module);
+void install_extractDbnetTextBoxes(facebook::jsi::Runtime &rt, facebook::jsi::Object &module);
+void install_ctcGreedyDecode(facebook::jsi::Runtime &rt, facebook::jsi::Object &module);
+} // namespace rnexecutorch::extensions::cv::ocr_ops
diff --git a/packages/react-native-executorch/src/constants.ts b/packages/react-native-executorch/src/constants.ts
index 7eafc57abf..87b4703c28 100644
--- a/packages/react-native-executorch/src/constants.ts
+++ b/packages/react-native-executorch/src/constants.ts
@@ -1301,3 +1301,99 @@ export type BlazeFaceLandmark = (typeof BLAZEFACE_LANDMARKS)[number];
* @category Types
*/
export type CocoLandmark = (typeof COCO_LANDMARKS)[number];
+export { alphabets, PPOCR_SYMBOLS } from './extensions/cv/tasks/ocr/charsets';
+
+/**
+ * PP-DocLayoutV3 region classes, in model output order (index = class id).
+ * @category Constants
+ */
+export const DOC_LAYOUT_LABELS = [
+ 'abstract',
+ 'algorithm',
+ 'aside_text',
+ 'chart',
+ 'content',
+ 'display_formula',
+ 'doc_title',
+ 'figure_title',
+ 'footer',
+ 'footer_image',
+ 'footnote',
+ 'formula_number',
+ 'header',
+ 'header_image',
+ 'image',
+ 'inline_formula',
+ 'number',
+ 'paragraph_title',
+ 'reference',
+ 'reference_content',
+ 'seal',
+ 'table',
+ 'text',
+ 'vertical_text',
+ 'vision_footnote',
+] as const;
+
+/**
+ * Type representing a valid PP-DocLayout region class string.
+ * @category Types
+ */
+export type DocLayoutLabel = (typeof DOC_LAYOUT_LABELS)[number];
+
+/**
+ * SLANet_plus table-structure token vocabulary (50 tokens; index = token id).
+ * @category Constants
+ */
+export const SLANET_STRUCTURE_VOCAB = [
+ 'sos',
+ '',
+ ' ',
+ '',
+ ' ',
+ '',
+ ' ',
+ '',
+ ' ',
+ ' colspan="2"',
+ ' colspan="3"',
+ ' colspan="4"',
+ ' colspan="5"',
+ ' colspan="6"',
+ ' colspan="7"',
+ ' colspan="8"',
+ ' colspan="9"',
+ ' colspan="10"',
+ ' colspan="11"',
+ ' colspan="12"',
+ ' colspan="13"',
+ ' colspan="14"',
+ ' colspan="15"',
+ ' colspan="16"',
+ ' colspan="17"',
+ ' colspan="18"',
+ ' colspan="19"',
+ ' colspan="20"',
+ ' rowspan="2"',
+ ' rowspan="3"',
+ ' rowspan="4"',
+ ' rowspan="5"',
+ ' rowspan="6"',
+ ' rowspan="7"',
+ ' rowspan="8"',
+ ' rowspan="9"',
+ ' rowspan="10"',
+ ' rowspan="11"',
+ ' rowspan="12"',
+ ' rowspan="13"',
+ ' rowspan="14"',
+ ' rowspan="15"',
+ ' rowspan="16"',
+ ' rowspan="17"',
+ ' rowspan="18"',
+ ' rowspan="19"',
+ ' rowspan="20"',
+ ' ',
+ 'eos',
+] as const;
diff --git a/packages/react-native-executorch/src/core/model.ts b/packages/react-native-executorch/src/core/model.ts
index e539afecdd..88ab695f85 100644
--- a/packages/react-native-executorch/src/core/model.ts
+++ b/packages/react-native-executorch/src/core/model.ts
@@ -75,6 +75,50 @@ export type ModelMethodMeta = {
outputTensorMeta: TensorMeta[];
};
+/**
+ * The legal range of a single dynamic input dimension: any size
+ * `min + k * step` within `[min, max]`.
+ * @category Types
+ */
+export type DimRange = {
+ /** The smallest legal size for this dimension. */
+ readonly min: number;
+ /** The largest legal size for this dimension. */
+ readonly max: number;
+ /** The increment between consecutive legal sizes (>= 1). */
+ readonly step: number;
+ /**
+ * Set when the dimension is a named symbolic size with no numeric bound the
+ * runtime resolves at execution time. `min`/`max` are then placeholders (0) and
+ * the dimension cannot be pre-resolved from metadata alone.
+ */
+ readonly symbol?: string;
+};
+
+/**
+ * An enumerated set of whole legal input shapes — enumerated-shape backends
+ * (e.g. CoreML) accept only these exact shapes. Mirrors the native
+ * `EnumeratedShapes`.
+ * @category Types
+ */
+export type EnumeratedShapes = readonly (readonly number[])[];
+
+/**
+ * The resolved shape constraint of a single tensor input, discriminated by
+ * `kind`:
+ *
+ * - `range` — per-dimension {@link DimRange} ranges (from a
+ * `get_dynamic_dims_` companion).
+ * - `enum` — {@link EnumeratedShapes} whole shapes (from a
+ * `get_enum_shapes_` companion).
+ * - `static` — a single static shape (no companion).
+ * @category Types
+ */
+export type InputShapeConstraint =
+ | { readonly kind: 'range'; readonly dims: readonly DimRange[] }
+ | { readonly kind: 'enum'; readonly shapes: EnumeratedShapes }
+ | { readonly kind: 'static'; readonly shape: readonly number[] };
+
/**
* A compiled, ready-to-run ExecuTorch model loaded into native memory.
*
@@ -104,6 +148,16 @@ export interface Model {
*/
getMethodMeta(methodName: string): ModelMethodMeta;
+ /**
+ * Returns the shape constraint of each tensor input of a method, resolved at
+ * load from the model's `get_dynamic_dims_` / `get_enum_shapes_`
+ * companions (or the single static shape when neither is present). One entry
+ * per tensor input, in the same order as `getMethodMeta().inputTensorMeta`.
+ * @param methodName The name of the exported method to inspect.
+ * @returns The per-tensor-input shape constraints.
+ */
+ getInputShapeConstraints(methodName: string): InputShapeConstraint[];
+
/**
* Executes a named model method synchronously.
*
diff --git a/packages/react-native-executorch/src/extensions/cv/ops/image.ts b/packages/react-native-executorch/src/extensions/cv/ops/image.ts
index 1d98b73c41..cf91ae369e 100644
--- a/packages/react-native-executorch/src/extensions/cv/ops/image.ts
+++ b/packages/react-native-executorch/src/extensions/cv/ops/image.ts
@@ -1,6 +1,8 @@
import { rnexecutorchJsi } from '../../../native/bridge';
import type { Tensor } from '../../../core/tensor';
import type { ImageFormat } from '../image';
+import type { Quad } from './quad';
+import type { BoundingBox } from './boxes';
/**
* Supported color conversion code presets (similar to OpenCV).
@@ -209,3 +211,97 @@ export function applyColormap(
'worklet';
return rnexecutorchJsi.cv.applyColormap(src, dst, colormap);
}
+
+/**
+ * Rotates `src` clockwise by `degCW` degrees (90, 180, or 270) into the
+ * pre-allocated `dst`. A 90/270 rotation swaps width and height, so `dst` must be
+ * sized with `src`'s height and width transposed.
+ * @category Typescript API
+ * @param src The source image tensor (HWC).
+ * @param dst The destination tensor, pre-sized for the rotation.
+ * @param degCW The clockwise rotation in degrees: 90, 180, or 270.
+ * @returns The destination tensor `dst`.
+ */
+export function rotate(src: Tensor, dst: Tensor, degCW: number): Tensor {
+ 'worklet';
+ return rnexecutorchJsi.cv.rotate(src, dst, degCW);
+}
+
+/**
+ * Options for {@link rectifyQuad}. `contentWidth` is the rectified content's width
+ * (px) in the canvas; `align` (default `'left'`) and `padMode`/`padValue` (default
+ * constant `0`) place and fill it.
+ * @category Types
+ */
+export type RectifyQuadOptions = {
+ readonly contentWidth: number;
+ readonly align?: 'left' | 'center';
+ readonly padMode?: 'constant' | 'cornerMean';
+ readonly padValue?: number;
+};
+
+/**
+ * Rectifies an oriented quad region of `src` into the flat pre-allocated canvas
+ * `dst` — perspective crop + resize-to-height + pad in one native pass. An
+ * axis-aligned bbox is a 4-corner quad; pass its corners to rectify a box.
+ * @category Typescript API
+ * @param src The source image tensor (HWC uint8).
+ * @param dst The pre-allocated destination canvas (HWC uint8).
+ * @param quad The region corners (TL, TR, BR, BL) in `src` pixels.
+ * @param opts Content width, alignment, and padding.
+ * @returns The destination tensor `dst`.
+ */
+export function rectifyQuad(
+ src: Tensor,
+ dst: Tensor,
+ quad: Quad,
+ opts: RectifyQuadOptions
+): Tensor {
+ 'worklet';
+ // The native op takes the corners as a flat [x0,y0,..,x3,y3] array.
+ const flat = quad.flatMap((p) => [p.x, p.y]);
+ return rnexecutorchJsi.cv.rectifyQuad(src, dst, flat, {
+ contentWidth: opts.contentWidth,
+ align: opts.align ?? 'left',
+ padMode: opts.padMode ?? 'constant',
+ padValue: opts.padValue ?? 0,
+ });
+}
+
+/**
+ * Warps `src` through a backward sampling field (`torch.grid_sample`-style — the
+ * grid gives, per output pixel, where to read from in `src`) into the
+ * pre-allocated `dst`, natively via `cv::remap`.
+ * @category Typescript API
+ * @param src The source image tensor (HWC uint8).
+ * @param grid The sampling field (`float32`, `[..,2,gH,gW]`, channel 0 = x, 1 = y,
+ * normalized to `[-1, 1]` with `align_corners=true`).
+ * @param dst The pre-allocated destination, same shape/dtype as `src`.
+ * @returns The destination tensor `dst`.
+ */
+export function warpByGrid(src: Tensor, grid: Tensor, dst: Tensor): Tensor {
+ 'worklet';
+ return rnexecutorchJsi.cv.warpByGrid(src, grid, dst);
+}
+
+/**
+ * Crops an axis-aligned `xyxy` region of the source tensor into the pre-allocated
+ * `dst` (a native `cv::Mat` ROI copy). `dst` must be sized `[ymax-ymin, xmax-xmin, C]`
+ * (both rounded/clamped to the source bounds). Unlike {@link restrictToBox}, this
+ * changes the shape; stays on-device (no JS buffer round-trip).
+ * @category Typescript API
+ * @param src The source image tensor (HWC).
+ * @param dst The pre-allocated destination tensor, sized to the clamped box.
+ * @param box The crop region in `xyxy` pixels.
+ * @returns The destination tensor `dst`.
+ */
+export function crop(src: Tensor, dst: Tensor, box: BoundingBox<'xyxy'>): Tensor {
+ 'worklet';
+ // Round here so the box matches a `Math.round`-sized `dst`; native clamps to bounds.
+ return rnexecutorchJsi.cv.crop(src, dst, [
+ Math.round(box.xmin),
+ Math.round(box.ymin),
+ Math.round(box.xmax),
+ Math.round(box.ymax),
+ ]);
+}
diff --git a/packages/react-native-executorch/src/extensions/cv/ops/index.ts b/packages/react-native-executorch/src/extensions/cv/ops/index.ts
index 84a274101d..4128d25527 100644
--- a/packages/react-native-executorch/src/extensions/cv/ops/index.ts
+++ b/packages/react-native-executorch/src/extensions/cv/ops/index.ts
@@ -1,3 +1,4 @@
export * as image from './image';
export * as boxes from './boxes';
export * as points from './points';
+export * as quad from './quad';
diff --git a/packages/react-native-executorch/src/extensions/cv/ops/quad.ts b/packages/react-native-executorch/src/extensions/cv/ops/quad.ts
new file mode 100644
index 0000000000..486b3f4932
--- /dev/null
+++ b/packages/react-native-executorch/src/extensions/cv/ops/quad.ts
@@ -0,0 +1,260 @@
+import { scalePoint, type Point } from './points';
+import type { BoundingBox, BoxFormat } from './boxes';
+
+/**
+ * An oriented quadrilateral in pixel space: the four corners ordered top-left,
+ * top-right, bottom-right, bottom-left. Orientation lives in the corners
+ * themselves.
+ * @category Types
+ */
+export type Quad = readonly Point[];
+
+const distance = (a: Point, b: Point): number => {
+ 'worklet';
+ return Math.hypot(b.x - a.x, b.y - a.y);
+};
+
+const interpolatePoint = (a: Point, b: Point, t: number): Point => {
+ 'worklet';
+ return { x: a.x + (b.x - a.x) * t, y: a.y + (b.y - a.y) * t };
+};
+
+/**
+ * Computes the axis-aligned bounding box enclosing a set of points, in the
+ * requested box format. Returns a zero box for empty input.
+ * @category Typescript API
+ * @typeParam F Bounding box coordinate format.
+ * @param points The points to enclose.
+ * @param format The coordinate format of the returned box.
+ * @returns The enclosing {@link BoundingBox} in `format`.
+ */
+export function boundsOfPoints(
+ points: readonly Point[],
+ format: F
+): BoundingBox {
+ 'worklet';
+ let xmin = Infinity;
+ let ymin = Infinity;
+ let xmax = -Infinity;
+ let ymax = -Infinity;
+ for (const p of points) {
+ if (p.x < xmin) xmin = p.x;
+ if (p.y < ymin) ymin = p.y;
+ if (p.x > xmax) xmax = p.x;
+ if (p.y > ymax) ymax = p.y;
+ }
+ if (points.length === 0) {
+ xmin = ymin = xmax = ymax = 0;
+ }
+ switch (format) {
+ case 'xyxy':
+ return { format: 'xyxy', xmin, ymin, xmax, ymax } as BoundingBox;
+ case 'xywh':
+ return { format: 'xywh', xmin, ymin, w: xmax - xmin, h: ymax - ymin } as BoundingBox;
+ case 'cxcywh':
+ return {
+ format: 'cxcywh',
+ cx: (xmin + xmax) / 2,
+ cy: (ymin + ymax) / 2,
+ w: xmax - xmin,
+ h: ymax - ymin,
+ } as BoundingBox;
+ default:
+ throw new Error(`boundsOfPoints: unsupported box format '${format}'.`);
+ }
+}
+
+/**
+ * Builds the axis-aligned bounding quad (ordered TL,TR,BR,BL) of an `xyxy` box —
+ * the corner-ordering counterpart of an axis-aligned {@link BoundingBox}.
+ * @category Typescript API
+ * @param box The `xyxy` box.
+ * @returns The four corners, ordered TL, TR, BR, BL.
+ */
+export function quadFromBounds(box: BoundingBox<'xyxy'>): Quad {
+ 'worklet';
+ return [
+ { x: box.xmin, y: box.ymin },
+ { x: box.xmax, y: box.ymin },
+ { x: box.xmax, y: box.ymax },
+ { x: box.xmin, y: box.ymax },
+ ];
+}
+
+/**
+ * Orders four corner points as top-left, top-right, bottom-right, bottom-left
+ * using their coordinate-sum and coordinate-difference extremes. Inputs that do
+ * not have exactly four points are returned unchanged.
+ * @category Typescript API
+ * @param points The four unordered corners.
+ * @returns The corners ordered TL, TR, BR, BL.
+ */
+export function orderQuad(points: readonly Point[]): Quad {
+ 'worklet';
+ if (points.length !== 4) {
+ return [...points];
+ }
+ // TL/BR are the corners with the min/max coordinate sum; TR/BL the min/max
+ // difference (y − x). indexOfMin/Max break ties on the lowest index.
+ const sum = points.map((p) => p.x + p.y);
+ const diff = points.map((p) => p.y - p.x);
+ const indexOfMin = (a: number[]) => a.indexOf(Math.min(...a));
+ const indexOfMax = (a: number[]) => a.indexOf(Math.max(...a));
+ const corners = [indexOfMin(sum), indexOfMin(diff), indexOfMax(sum), indexOfMax(diff)]; // TL, TR, BR, BL
+ // Degenerate quads (duplicate or collinear corners) can map two roles to the
+ // same point; the heuristic is meaningless there, so return the points
+ // unchanged and let the resulting near-zero-size box be dropped downstream.
+ if (new Set(corners).size !== 4) {
+ return [...points];
+ }
+ return corners.map((i) => points[i]!);
+}
+
+/**
+ * Computes the width and height (in pixels) of an ordered TL,TR,BR,BL quad, taking
+ * the longer of each pair of opposite sides.
+ * @category Typescript API
+ * @param ordered The quad corners ordered TL, TR, BR, BL.
+ * @returns The quad's width and height in pixels.
+ */
+export function quadSize(ordered: Quad): { width: number; height: number } {
+ 'worklet';
+ const [tl, tr, br, bl] = ordered as [Point, Point, Point, Point];
+ const width = Math.max(distance(tl, tr), distance(bl, br));
+ const height = Math.max(distance(tl, bl), distance(tr, br));
+ return { width, height };
+}
+
+/**
+ * Maps a quad expressed in a resized (letterboxed) frame back to the original
+ * image frame, clamping the result to the image bounds.
+ * @category Typescript API
+ * @param quad The quad in the resized frame.
+ * @param fromWidth The width of the resized frame the quad is expressed in.
+ * @param fromHeight The height of the resized frame the quad is expressed in.
+ * @param toWidth The original image width.
+ * @param toHeight The original image height.
+ * @returns The four corners in original image pixels.
+ */
+export function mapQuadToImage(
+ quad: Quad,
+ fromWidth: number,
+ fromHeight: number,
+ toWidth: number,
+ toHeight: number
+): Quad {
+ 'worklet';
+ return quad.map((p) => {
+ const m = scalePoint(p, {
+ from: { width: fromWidth, height: fromHeight },
+ to: { width: toWidth, height: toHeight },
+ resizeMode: 'letterbox',
+ });
+ return { x: Math.max(0, Math.min(m.x, toWidth)), y: Math.max(0, Math.min(m.y, toHeight)) };
+ });
+}
+
+/**
+ * Splits an ordered TL,TR,BR,BL quad into `parts` equal vertical bands (each an
+ * ordered quad), top to bottom. `parts <= 1` returns the quad unchanged.
+ * @category Typescript API
+ * @param ordered The quad corners ordered TL, TR, BR, BL.
+ * @param parts The number of equal vertical bands to split into.
+ * @returns The bands as ordered TL,TR,BR,BL quads, top to bottom.
+ */
+export function splitTallQuad(ordered: Quad, parts: number): Quad[] {
+ 'worklet';
+ if (parts <= 1) {
+ return [ordered];
+ }
+ const [tl, tr, br, bl] = ordered as [Point, Point, Point, Point];
+ const out: Quad[] = [];
+ for (let i = 0; i < parts; i++) {
+ const t0 = i / parts;
+ const t1 = (i + 1) / parts;
+ out.push([
+ interpolatePoint(tl, bl, t0),
+ interpolatePoint(tr, br, t0),
+ interpolatePoint(tr, br, t1),
+ interpolatePoint(tl, bl, t1),
+ ]);
+ }
+ return out;
+}
+
+/**
+ * Splits an ordered TL,TR,BR,BL quad into `parts` equal horizontal segments
+ * (each an ordered quad), left to right. `parts <= 1` returns the quad
+ * unchanged.
+ * @category Typescript API
+ * @param ordered The quad corners ordered TL, TR, BR, BL.
+ * @param parts The number of equal horizontal segments to split into.
+ * @returns The segments as ordered TL,TR,BR,BL quads, left to right.
+ */
+export function splitWideQuad(ordered: Quad, parts: number): Quad[] {
+ 'worklet';
+ if (parts <= 1) {
+ return [ordered];
+ }
+ const [tl, tr, br, bl] = ordered as [Point, Point, Point, Point];
+ const out: Quad[] = [];
+ for (let i = 0; i < parts; i++) {
+ const t0 = i / parts;
+ const t1 = (i + 1) / parts;
+ out.push([
+ interpolatePoint(tl, tr, t0),
+ interpolatePoint(tl, tr, t1),
+ interpolatePoint(bl, br, t1),
+ interpolatePoint(bl, br, t0),
+ ]);
+ }
+ return out;
+}
+
+/**
+ * Computes the axis-aligned bounding quad (ordered TL,TR,BR,BL) enclosing a set of
+ * quads. Returns a zero quad for empty input.
+ * @category Typescript API
+ * @param quads The quads to enclose.
+ * @returns The four enclosing corners, ordered TL, TR, BR, BL.
+ */
+export function boundingQuadOf(quads: readonly Quad[]): Quad {
+ 'worklet';
+ const all: Point[] = [];
+ for (const q of quads) {
+ for (const p of q) {
+ all.push(p);
+ }
+ }
+ const { xmin, ymin, xmax, ymax } = boundsOfPoints(all, 'xyxy');
+ return [
+ { x: xmin, y: ymin },
+ { x: xmax, y: ymin },
+ { x: xmax, y: ymax },
+ { x: xmin, y: ymax },
+ ];
+}
+
+/**
+ * Builds oriented quads from a detector's flat output array — 8 numbers per box:
+ * `x0,y0,..,x3,y3`.
+ * @category Typescript API
+ * @param flat The flat number array from a native detector decode.
+ * @returns The parsed quads.
+ */
+export function quadsFromFlat(flat: readonly number[]): Quad[] {
+ 'worklet';
+ if (flat.length % 8 !== 0) {
+ throw new Error(`quadsFromFlat: expected a multiple of 8 values, got ${flat.length}.`);
+ }
+ const quads: Quad[] = [];
+ for (let i = 0; i < flat.length; i += 8) {
+ quads.push([
+ { x: flat[i]!, y: flat[i + 1]! },
+ { x: flat[i + 2]!, y: flat[i + 3]! },
+ { x: flat[i + 4]!, y: flat[i + 5]! },
+ { x: flat[i + 6]!, y: flat[i + 7]! },
+ ]);
+ }
+ return quads;
+}
diff --git a/packages/react-native-executorch/src/extensions/cv/tasks/ocr/charsets.ts b/packages/react-native-executorch/src/extensions/cv/tasks/ocr/charsets.ts
new file mode 100644
index 0000000000..8917d78511
--- /dev/null
+++ b/packages/react-native-executorch/src/extensions/cv/tasks/ocr/charsets.ts
@@ -0,0 +1,23 @@
+/* eslint-disable @cspell/spellchecker */
+/* eslint-disable camelcase */
+export const alphabets = {
+ cyrillic:
+ '0123456789!"#$%&\'()*+,-./:;<=>?@[\\]^_`{|}~ €₽ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyzАБВГДЕЁЖЗИЙКЛМНОПРСТУФХЦЧШЩЪЫЬЭЮЯабвгдеёжзийклмнопрстуфхцчшщъыьэюяЂђЃѓЄєІіЇїЈјЉљЊњЋћЌќЎўЏџҐґҒғҚқҮүҲҳҶҷӀӏӢӣӨөӮӯ',
+ english:
+ '0123456789!"#$%&\'()*+,-./:;<=>?@[\\]^_`{|}~ €ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz',
+ latin:
+ ' !"#$%&\'()*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~ªÀÁÂÃÄÅÆÇÈÉÊËÌÍÎÏÐÑÒÓÔÕÖØÙÚÛÜÝÞßàáâãäåæçèéêëìíîïðñòóôõöøùúûüýþÿĀāĂ㥹ĆćČčĎďĐđĒēĖėĘęĚěĞğĨĩĪīĮįİıĶķĹĺĻļĽľŁłŃńŅņŇňŒœŔŕŘřŚśŞşŠšŤťŨũŪūŮůŲųŸŹźŻżŽžƏƠơƯưȘșȚțə̇ḌḍḶḷṀṁṂṃṄṅṆṇṬṭẠạẢảẤấẦầẨẩẪẫẬậẮắẰằẲẳẴẵẶặẸẹẺẻẼẽẾếỀềỂểỄễỆệỈỉỊịỌọỎỏỐốỒồỔổỖỗỘộỚớỜờỞởỠỡỢợỤụỦủỨứỪừỬửỮữỰựỲỳỴỵỶỷỸỹ€',
+ japanese:
+ ' !"#$%&\'()*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~‥…※♪、。々〈〉《》「」『』【】〔〕あぃいうぇえおかがきぎくぐけげこごさざしじすずせぜそぞただちぢっつづてでとどなにぬねのはばぱひびぴふぶぷへべぺほぼぽまみむめもゃやゅゆょよらりるれろわをんァアィイゥウェエォオカガキギクグケゲコゴサザシジスズセゼソゾタダチヂッツヅテデトドナニヌネノハバパヒビピフブプヘベペホボポマミムメモャヤュユョヨラリルレロワヱヲンヴヶ・ー一丁七万丈三上下不与丑世丘丙丞両並中串丸丹主乃久之乗乙九也乱乳乾亀了予争事二互五井亘亜亡交亥亨享京亭亮人仁今介仏仕他付仙代令以仮仰仲件任企伊伍伎伏休会伝伯伴伶伸似伽位低住佐佑体何余作佳使例侍侑供依価便係俊俗保信俣修俵倉個倍倒倖候借値倫倭倶偉偏健側偵偽傍傑備債傷働像僧儀優允元兄充先光克免兎児党入全八公六共兵其具典兼内円再写冠冨冬冴冶冷凍凡処凪出刀刃分切刈刑列初判別利制刷券刺刻則削前剛剣剤剥副割創劇力功加助労効勅勇勉動勘務勝勢勤勧勲包化北匠匡区医十千升午半卒卓協南単博占卯印危即卵卸厚原厨厳去参又叉及友双反収取受叡口古句只叫召可台史右叶号司各合吉同名后吏吐向君吟否吸吹吾呂呉告呑周味呼命和咲哀品哉員哲唄唐唯唱商問啓善喜喬喰営嗣嘉噌器四回因団囲図固国國園土圧在圭地坂均坊坐坪垂型垢垣埋城埜域執基埼堀堂堅堤堰報場堺塔塗塚塩塵境墓増墨墳壁壇壊士壬壮声壱売壷変夏夕外多夜夢大天太夫央失夷奇奈奉奏契奥奨女奴好如妃妙妹妻姉始姓委姥姫姿威娘婆婚婦嫌嬉子孔字存孝孟季孤学孫宅宇守安完宏宗官宙定宜宝実客宣室宥宮宰害家容宿寂寄寅密富寒寛寝察寧審寸寺対寿封専射将尊尋導小少尚尭就尺尻尼尽尾尿局居屈屋展属層屯山岐岡岩岬岱岳岸峠峡峨峯峰島峻崇崎崩嵐嵩嵯嶋嶺巌川州巡巣工左巧巨差己巳巴巻巾市布帆希帝師席帯帰帳常帽幅幌幕幡幣干平年幸幹幽幾庁広庄床序底店府度座庫庭庵康庸廃廉廣延建廻弁式弐弓引弘弟弥弦弱張強弾当形彦彩彪彫彬彰影役彼往征径待律後徒従得御復微徳徹心必忌忍志応忠快念怒怜思急性恋恐恒恩恭息恵悌悟悠患悦悪悲情惇惑惟惣想意愚愛感慈態慎慣慧慶憂憲憶懐懸戎成我戒戦戯戸戻房所扇手才打払扶承技投抗折抜抱押担拓拝拡拳拾持指挙振捕捨捷掃排掘掛採探接推掻提揖揚換揮援揺損摂摘摩摺撃撫播撮操擦擬支改攻放政故敏救敗教敢散敦敬数整敵敷文斉斎斐斑斗料斜斤断斯新方於施旅旋族旗日旦旧旨早旬旭旺昂昆昇昌明易星映春昭是昼時晃晋晩普景晴晶智暁暖暗暢暦暮暴曇曙曜曲曳更書曹曽曾替最月有朋服朔朗望朝期木未末本札朱朴杉李杏材村杖杜束条来杭東杵松板析枕林枚果枝枯架柄柊柏柑染柔柚柱柳柴査柿栃栄栖栗校株核根格桂桃案桐桑桜桝桧桶梁梅梓梢梨梯械梶棄棒棚棟森椋植椎検椿楊楓楠楢業楯極楼楽榊榎榛構槌様槙槻樋標模権横樫樹樺樽橋橘機檀櫛欠次欣欧欲欽歌歓止正此武歩歯歳歴死殊残殖段殺殻殿毅母毎毒比毛氏民気水氷永汀汁求汐汗汚江池汰汲決沈沓沖沙沢河油治沼泉泊法波泣泥注泰洋洗洞津洪洲活派流浄浅浜浦浩浪浮浴海消涌涙液涼淀淑淡深淳淵混添清済渉渋渓渕渚減渡渥温測港湊湖湧湯湾湿満源準溜溝溶滅滋滑滝漁漆漏演漢漬潔潜潟潤潮潰澄澤激濃濱瀧瀬灘火灯灰災炉炎炭点為烈烏無焦然焼煙照煮熊熟熱燃燈燕燦燭爆爪父爽爾片版牛牟牧物特犬犯状狂狐狗狩独狭狼猛猪猫献猿獄獅獣玄率玉王玖玲珍珠現球理琉琢琳琴瑚瑛瑞瑠瑳璃環瓜瓦瓶甘甚生産用甫田由甲申男町画界畑畔留畜畝畠略番異畳疾病症痛療発登白百的皆皇皮皿盆益盗盛盟監盤目盲直相省眉看県眞真眠眼着督睦瞬瞳矢知矩短石砂研砲破硫硬碑碧碩確磁磐磨磯礁示礼社祇祈祉祐祖祝神祢祥票祭禁禄禅禎福禰秀私秋科秘秦秩称移稀程税稔稗稚種稲穂積穏穴究空突窓窪立竜章童竪端競竹笑笛笠符第笹筆等筋筑筒答策箇箕算管箱箸節範築篠篤篭簡簾籍米粉粒粕粗粟粥精糖糞糠糸系紀約紅紋納純紗紘紙級素紡索紫細紳紹紺終組経結絡絢給統絵絶絹継続綜維綱網綾綿緋総緑緒線締編緩練縁縄縦縫縮績繁織繰罪置羅羊美群義羽翁習翔翠翼耀老考者耐耕耳耶聖聞聡聴職肇肉肌肝股肥肩育肺背胞胡胤胸能脂脇脈脚脱脳腐腕腫腰腸腹膜膳臣臥臨自臭至致臼興舌舎舘舛舜舞舟航般船艦良色艶芋芙芝芥芦花芳芸芹芽苅苑苔苗若苦苫英茂茄茅茉茜茨茶草荒荘荷荻莉菅菊菌菓菖菜華菱萌萩萱落葉葛葦葵蒔蒲蒸蒼蓋蓑蓬蓮蔦蔭蔵蕗薄薩薫薬薮藁藍藤藻蘇蘭虎虚虫虹虻蚊蛇蛍蛭蜂蜜蝦蝶融螺蟹蟻血衆行術街衛衝衡衣表袋袖被裁裂装裏裕補裟裸製複西要覆覇見規視覚覧親観角解触言計訓託記訪設許訳訴診証評詞詠試詩詰話誉誌認誓誘語誠誤説読課調談請諏論諭諸謙講謝謹識警議譲護讃谷豆豊豚象豪貝貞負財貢貧貨販貫責貯貴買貸費賀賃資賞賢質赤赦走起超越足跡路跳踏身車軌軍軒軟転軸軽載輔輝輪輸辛辞辰農辺辻込迎近返迦迫述迷追退送逃逆透途通速造逢連週進逸遅遊運過道達違遠遣遥適選遺遼避邑那邦邪郁郎郡部郭郵郷都配酒酔酢酸醍醐采釈里重野量金釘釜針釣鈴鉄鉛鉢鉱鉾銀銃銅銘銭鋭鋼錦録鍋鍛鍬鍵鎌鎖鎮鏡鐘鑑長門閉開閑間関閣闇闘阪防阿陀附降限院陣除陰陳陵陶陸険陽隅隆隈隊階随隔際障隠隣隼雀雁雄雅集雑雛離難雨雪雲零雷電震霊霜霞霧露青靖静非面革鞍鞠韓音響頂頃項順須預頓領頭頼題額顔顕願類風飛食飯飲飼飽飾餅養館首香馨馬駄駅駆駐駒駿騎験骨高髪鬼魁魂魅魔魚鮎鮫鮮鯉鯨鳥鳩鳳鳴鴨鴻鵜鶏鶴鷲鷹鷺鹿麓麗麦麻麿黄黒黙鼓鼠鼻齢龍*',
+ zh_sim:
+ ' !"#$%&\'()*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~。〈〉《》「」『』一丁七万丈三上下不与丐丑专且丕世丘丙业丛东丝丞丢两严丧个丫中丰串临丸丹为主丽举乃久么义之乌乍乎乏乐乒乓乔乖乘乙乜九乞也习乡书乩买乱乳乾了予争事二亍于亏云互亓五井亘亚些亟亡亢交亥亦产亨亩享京亭亮亲亳亵人亿什仁仂仃仄仅仆仇仉今介仍从仑仓仔仕他仗付仙仞仟仡代令以仨仪仫们仰仲仳仵件价任份仿企伉伊伍伎伏伐休众优伙会伛伞伟传伢伤伦伧伪伫伯估伴伶伸伺似伽佃但位低住佐佑体何佗佘余佚佛作佝佞佟你佣佤佥佩佬佯佰佳佴佶佻佼佾使侃侄侈侉例侍侏侑侔侗供依侠侣侥侦侧侨侩侪侬侮侯侵便促俄俅俊俎俏俐俑俗俘俚俜保俞俟信俣俦俨俩俪俭修俯俱俳俸俺俾倌倍倏倒倔倘候倚倜借倡倥倦倨倩倪倬倭债值倾偃假偈偌偎偏偕做停健偬偶偷偻偾偿傀傅傈傍傣傥傧储傩催傲傻像僖僚僦僧僬僭僮僳僵僻儆儇儋儒儡儿兀允元兄充兆先光克免兑兔兕兖党兜兢入全八公六兮兰共关兴兵其具典兹养兼兽冀冁内冈冉册再冒冕冗写军农冠冢冤冥冬冯冰冱冲决况冶冷冻冼冽净凄准凇凉凋凌减凑凛凝几凡凤凫凭凯凰凳凶凸凹出击凼函凿刀刁刃分切刈刊刍刎刑划刖列刘则刚创初删判刨利别刭刮到刳制刷券刹刺刻刽刿剀剁剂剃削剌前剐剑剔剖剜剞剡剥剧剩剪副割剽剿劁劂劈劐劓力劝办功加务劢劣动助努劫劬劭励劲劳劾势勃勇勉勋勐勒勖勘募勤勰勺勾勿匀包匆匈匍匏匐匕化北匙匝匠匡匣匦匪匮匹区医匾匿十千卅升午卉半华协卑卒卓单卖南博卜卞卟占卡卢卣卤卦卧卫卮卯印危即却卵卷卸卺卿厂厄厅历厉压厌厍厕厘厚厝原厢厣厥厦厨厩厮去县叁参又叉及友双反发叔取受变叙叛叟叠口古句另叨叩只叫召叭叮可台叱史右叵叶号司叹叻叼叽吁吃各吆合吉吊同名后吏吐向吒吓吕吗君吝吞吟吠吡吣否吧吨吩含听吭吮启吱吲吴吵吸吹吻吼吾呀呃呆呈告呋呐呓呔呕呖呗员呙呛呜呢呤呦周呱呲味呵呶呷呸呻呼命咀咂咄咆咋和咎咏咐咒咔咕咖咙咚咛咝咣咤咦咧咨咩咪咫咬咭咯咱咳咴咸咻咽咿哀品哂哄哆哇哈哉哌响哎哏哐哑哓哔哕哗哙哚哝哞哟哥哦哧哨哩哪哭哮哲哳哺哼哽哿唁唆唇唉唏唐唑唔唛唠唢唣唤唧唪唬售唯唱唳唷唼唾唿啁啃啄商啉啊啐啕啖啜啡啤啥啦啧啪啬啭啮啵啶啷啸啻啼啾喀喁喂喃善喇喈喉喊喋喏喑喔喘喙喜喝喟喧喱喳喵喷喹喻喽喾嗄嗅嗉嗌嗍嗑嗒嗓嗔嗖嗜嗝嗟嗡嗣嗤嗥嗦嗨嗪嗫嗬嗯嗲嗳嗵嗷嗽嗾嘀嘁嘈嘉嘌嘎嘏嘘嘛嘞嘟嘣嘤嘧嘬嘭嘱嘲嘴嘶嘹嘻嘿噌噍噎噔噗噙噜噢噤器噩噪噫噬噱噶噻噼嚅嚆嚎嚏嚓嚣嚯嚷嚼囊囔囚四囝回囟因囡团囤囫园困囱围囵囹固国图囿圃圄圆圈圉圊圜土圣在圩圪圬圭圮圯地圳圹场圻圾址坂均坊坌坍坎坏坐坑块坚坛坜坝坞坟坠坡坤坦坨坩坪坫坭坯坳坷坻坼垂垃垄垅垆型垌垒垓垛垠垡垢垣垤垦垧垩垫垭垮垲垸埂埃埋城埏埒埔埕埘埙埚埝域埠埤埭埯埴埸培基埽堂堆堇堋堍堑堕堙堞堠堡堤堪堰堵塄塌塍塑塔塘塞塥填塬塾墀墁境墅墉墒墓墙增墟墨墩墼壁壅壑壕壤士壬壮声壳壶壹处备复夏夔夕外夙多夜够夤夥大天太夫夭央夯失头夷夸夹夺夼奁奂奄奇奈奉奋奎奏契奔奕奖套奘奚奠奢奥女奴奶奸她好妁如妃妄妆妇妈妊妍妒妓妖妗妙妞妣妤妥妨妩妪妫妮妯妲妹妻妾姆姊始姐姑姒姓委姗姘姚姜姝姣姥姨姬姹姻姿威娃娄娅娆娇娈娉娌娑娓娘娜娟娠娣娥娩娱娲娴娶娼婀婆婉婊婕婚婢婧婪婴婵婶婷婺婿媒媚媛媪媲媳媵媸媾嫁嫂嫉嫌嫒嫔嫖嫘嫜嫠嫡嫣嫦嫩嫫嫱嬉嬖嬗嬴嬷孀子孑孓孔孕字存孙孚孛孜孝孟孢季孤孥学孩孪孬孰孱孳孵孺孽宁它宄宅宇守安宋完宏宓宕宗官宙定宛宜宝实宠审客宣室宥宦宪宫宰害宴宵家宸容宽宾宿寂寄寅密寇富寐寒寓寝寞察寡寤寥寨寮寰寸对寺寻导寿封射将尉尊小少尔尕尖尘尚尜尝尤尥尧尬就尴尸尹尺尻尼尽尾尿局屁层居屈屉届屋屎屏屐屑展屙属屠屡屣履屦屯山屹屺屿岁岂岈岌岍岐岑岔岖岗岘岙岚岛岢岣岩岫岬岭岱岳岵岷岸岿峁峄峋峒峙峡峤峥峦峨峪峭峰峻崂崃崆崇崎崔崖崛崞崤崦崧崩崭崮崴崽嵇嵊嵋嵌嵘嵛嵝嵩嵫嵬嵯嵴嶂嶙嶝嶷巅巍川州巡巢工左巧巨巩巫差巯己已巳巴巷巽巾币市布帅帆师希帏帐帑帔帕帖帘帙帚帛帜帝带帧席帮帱帷常帻帼帽幂幄幅幌幔幕幛幞幡幢干平年并幸幺幻幼幽广庀庄庆庇床庋序庐庑库应底庖店庙庚府庞废庠庥度座庭庳庵庶康庸庹庾廉廊廒廓廖廛廨廪延廷建廿开弁异弃弄弈弊弋式弑弓引弗弘弛弟张弥弦弧弩弭弯弱弹强弼彀归当录彖彗彘彝形彤彦彩彪彬彭彰影彳彷役彻彼往征徂径待徇很徉徊律後徐徒徕得徘徙徜御徨循徭微徵德徼徽心必忆忉忌忍忏忐忑忒忖志忘忙忝忠忡忤忧忪快忭忮忱念忸忻忽忾忿怀态怂怃怄怅怆怊怍怎怏怒怔怕怖怙怛怜思怠怡急怦性怨怩怪怫怯怵总怼怿恁恂恃恋恍恐恒恕恙恚恝恢恣恤恧恨恩恪恫恬恭息恰恳恶恸恹恺恻恼恽恿悃悄悉悌悍悒悔悖悚悛悝悟悠患悦您悫悬悭悯悱悲悴悸悻悼情惆惊惋惑惕惘惚惜惝惟惠惦惧惨惩惫惬惭惮惯惰想惴惶惹惺愀愁愆愈愉愍愎意愕愚感愠愣愤愦愧愫愿慈慊慌慎慑慕慝慢慧慨慰慵慷憋憎憔憝憧憨憩憬憷憾懂懈懊懋懑懒懦懵懿戆戈戊戋戌戍戎戏成我戒戕或戗战戚戛戟戡戢戥截戬戮戳戴户戽戾房所扁扃扇扈扉手扌才扎扑扒打扔托扛扣扦执扩扪扫扬扭扮扯扰扳扶批扼找承技抄抉把抑抒抓投抖抗折抚抛抟抠抡抢护报抨披抬抱抵抹抻押抽抿拂拄担拆拇拈拉拊拌拍拎拐拒拓拔拖拗拘拙拚招拜拟拢拣拥拦拧拨择括拭拮拯拱拳拴拶拷拼拽拾拿持挂指挈按挎挑挖挚挛挝挞挟挠挡挢挣挤挥挨挪挫振挲挹挺挽捂捃捅捆捉捋捌捍捎捏捐捕捞损捡换捣捧捩捭据捱捶捷捺捻掀掂掇授掉掊掌掎掏掐排掖掘掠探掣接控推掩措掬掭掮掰掳掴掷掸掺掼掾揄揆揉揍揎描提插揖揞揠握揣揩揪揭援揶揸揽揿搀搁搂搅搋搌搏搐搓搔搛搜搞搠搡搦搪搬搭搴携搽摁摄摅摆摇摈摊摒摔摘摞摧摩摭摸摹摺撂撄撅撇撑撒撕撖撙撞撤撩撬播撮撰撵撷撸撺撼擀擂擅操擎擐擒擘擞擢擤擦攀攉攒攘攥攫攮支收攸改攻放政故效敉敌敏救敕敖教敛敝敞敢散敦敫敬数敲整敷文斋斌斐斑斓斗料斛斜斟斡斤斥斧斩斫断斯新方於施旁旃旄旅旆旋旌旎族旒旖旗无既日旦旧旨早旬旭旮旯旰旱时旷旺昀昂昃昆昊昌明昏易昔昕昙昝星映春昧昨昭是昱昴昵昶昼显晁晃晋晌晏晒晓晔晕晖晗晚晟晡晤晦晨普景晰晴晶晷智晾暂暄暇暌暑暖暗暝暧暨暮暴暹暾曙曛曜曝曦曩曰曲曳更曷曹曼曾替最月有朊朋服朐朔朕朗望朝期朦木未末本札术朱朴朵机朽杀杂权杆杈杉杌李杏材村杓杖杜杞束杠条来杨杪杭杯杰杲杳杵杷杼松板极构枇枉枋析枕林枘枚果枝枞枢枣枥枧枨枪枫枭枯枰枳枵架枷枸柁柃柄柏某柑柒染柔柘柙柚柜柝柞柠柢查柩柬柯柰柱柳柴柽柿栀栅标栈栉栊栋栌栎栏树栓栖栗栝校栩株栲栳样核根格栽栾桀桁桂桃桄桅框案桉桊桌桎桐桑桓桔桕桠桡桢档桤桥桦桧桨桩桫桴桶桷梁梃梅梆梏梓梗梢梦梧梨梭梯械梳梵检棂棉棋棍棒棕棘棚棠棣森棰棱棵棹棺棼椁椅椋植椎椐椒椟椠椤椭椰椴椹椽椿楂楔楗楚楝楞楠楣楦楫楮楷楸楹楼榀概榄榆榇榈榉榍榔榕榛榜榧榨榫榭榱榴榷榻槁槊槌槎槐槔槛槟槠槭槲槽槿樊樗樘樟模樨横樯樱樵樽樾橄橇橐橘橙橛橡橥橱橹橼檀檄檎檐檑檗檠檩檫檬欠次欢欣欤欧欲欷欺款歃歆歇歉歌歙止正此步武歧歪歹死歼殁殂殃殄殆殇殉殊残殍殒殓殖殚殛殡殪殳殴段殷殿毁毂毅毋母每毒毓比毕毖毗毙毛毡毪毫毯毳毵毹毽氅氆氇氍氏氐民氓气氕氖氘氙氚氛氟氡氢氤氦氧氨氩氪氮氯氰氲水永氽汀汁求汆汇汉汊汐汔汕汗汛汜汝汞江池污汤汨汩汪汰汲汴汶汹汽汾沁沂沃沅沆沈沉沌沏沐沓沔沙沛沟没沣沤沥沦沧沩沪沫沭沮沱河沸油治沼沽沾沿泄泅泉泊泌泐泓泔法泖泗泛泞泠泡波泣泥注泪泫泮泯泰泱泳泵泷泸泺泻泼泽泾洁洄洇洋洌洎洒洗洙洚洛洞津洧洪洫洮洱洲洳洵洹活洼洽派流浃浅浆浇浈浊测浍济浏浑浒浓浔浙浚浜浞浠浣浦浩浪浮浯浴海浸浼涂涅消涉涌涎涑涓涔涕涛涝涞涟涠涡涣涤润涧涨涩涪涫涮涯液涵涸涿淀淄淅淆淇淋淌淑淖淘淙淝淞淠淡淤淦淫淬淮深淳混淹添淼清渊渌渍渎渐渑渔渖渗渚渝渠渡渣渤渥温渫渭港渲渴游渺湃湄湍湎湔湖湘湛湟湫湮湾湿溃溅溆溉溏源溘溜溟溢溥溧溪溯溱溲溴溶溷溺溻溽滁滂滇滋滏滑滓滔滕滗滚滞滟滠满滢滤滥滦滨滩滴滹漂漆漉漏漓演漕漠漤漩漪漫漭漯漱漳漶漾潆潇潋潍潘潜潞潢潦潭潮潲潴潸潺潼澄澈澉澌澍澎澜澡澧澳澶澹激濂濉濑濒濞濠濡濮濯瀑瀚瀛瀣瀵瀹灌灏灞火灭灯灰灵灶灸灼灾灿炀炅炉炊炎炒炔炕炖炙炜炝炫炬炭炮炯炱炳炷炸点炻炼炽烀烁烂烃烈烊烘烙烛烟烤烦烧烨烩烫烬热烯烷烹烽焉焊焐焓焕焖焘焙焚焦焯焰焱然煅煊煌煎煜煞煤煦照煨煮煲煳煸煺煽熄熊熏熔熘熙熟熠熨熬熵熹燃燎燔燕燠燥燧燮燹爆爝爨爪爬爰爱爵父爷爸爹爻爽爿片版牌牍牒牖牙牛牝牟牡牢牦牧物牮牯牲牵特牺牾犀犁犄犊犋犍犏犒犟犬犯犰犴状犷犸犹狁狂狃狄狈狍狎狐狒狗狙狞狠狡狨狩独狭狮狯狰狱狲狳狴狷狸狺狻狼猁猃猊猎猕猖猗猛猜猝猞猡猢猥猩猪猫猬献猱猴猷猸猹猾猿獍獐獒獗獠獬獭獯獾玄率玉王玎玑玖玛玢玩玫玮环现玲玳玷玺玻珀珂珈珉珊珍珏珐珑珙珞珠珥珧珩班珲球琅理琉琏琐琚琛琢琥琦琨琪琬琮琰琳琴琵琶琼瑁瑕瑗瑙瑚瑛瑜瑞瑟瑭瑰瑶瑾璀璁璃璇璋璎璐璜璞璧璨璩瓒瓜瓞瓠瓢瓣瓤瓦瓮瓯瓴瓶瓷瓿甄甏甑甓甘甙甚甜生甥用甩甫甬甭田由甲申电男甸町画甾畀畅畈畋界畎畏畔留畚畛畜略畦番畲畴畸畹畿疃疆疋疏疑疔疖疗疙疚疝疟疠疡疣疤疥疫疬疮疯疰疱疲疳疴疵疸疹疼疽疾痂痃痄病症痈痉痊痍痒痔痕痘痛痞痢痣痤痦痧痨痪痫痰痱痴痹痼痿瘀瘁瘃瘅瘊瘌瘐瘗瘘瘙瘛瘟瘠瘢瘤瘥瘦瘩瘪瘫瘭瘰瘳瘴瘵瘸瘼瘾瘿癀癃癌癍癔癖癜癞癣癫癯癸登白百皂的皆皇皈皋皎皑皓皖皙皤皮皱皲皴皿盂盅盆盈益盍盎盏盐监盒盔盖盗盘盛盟盥目盯盱盲直相盹盼盾省眄眇眈眉看眍眙眚真眠眢眦眨眩眭眯眵眶眷眸眺眼着睁睃睇睐睑睚睛睡睢督睥睦睨睫睬睹睽睾睿瞀瞄瞅瞌瞍瞎瞑瞒瞟瞠瞢瞥瞧瞩瞪瞬瞰瞳瞵瞻瞽瞿矍矗矛矜矢矣知矧矩矫矬短矮石矶矸矽矾矿砀码砂砉砌砍砑砒研砖砗砘砚砜砝砟砣砥砧砭砰破砷砸砹砺砻砼砾础硅硇硌硎硐硒硕硖硗硝硪硫硬硭确硷硼碇碉碌碍碎碑碓碗碘碚碛碜碟碡碣碥碧碰碱碲碳碴碹碾磁磅磉磊磋磐磔磕磙磨磬磲磴磷磺礁礅礓礞礤礴示礻礼社祀祁祆祈祉祓祖祗祚祛祜祝神祟祠祢祥祧票祭祯祷祸祺禀禁禄禅禊福禚禧禳禹禺离禽禾秀私秃秆秉秋种科秒秕秘租秣秤秦秧秩秫秭积称秸移秽稀稂稆程稍税稔稗稚稞稠稣稳稷稻稼稽稿穆穑穗穰穴究穷穸穹空穿窀突窃窄窈窍窑窒窕窖窗窘窜窝窟窠窥窦窨窬窭窳窿立竖站竞竟章竣童竦竭端竹竺竽竿笃笄笆笈笊笋笏笑笔笕笙笛笞笠笤笥符笨笪笫第笮笱笳笸笺笼笾筅筇等筋筌筏筐筑筒答策筘筚筛筝筠筢筮筱筲筵筷筹筻签简箅箍箐箔箕算箜管箢箦箧箨箩箪箫箬箭箱箴箸篁篆篇篌篑篓篙篚篝篡篥篦篪篮篱篷篼篾簇簋簌簏簖簟簦簧簪簸簿籀籁籍米籴类籼籽粉粑粒粕粗粘粜粝粞粟粤粥粪粮粱粲粳粹粼粽精糁糅糇糈糊糌糍糕糖糗糙糜糟糠糨糯系紊素索紧紫累絮絷綦綮縻繁繇纂纛纠纡红纣纤纥约级纨纩纪纫纬纭纯纰纱纲纳纵纶纷纸纹纺纽纾线绀绁绂练组绅细织终绉绊绋绌绍绎经绐绑绒结绔绕绗绘给绚绛络绝绞统绠绡绢绣绥绦继绨绩绪绫续绮绯绰绲绳维绵绶绷绸绺绻综绽绾绿缀缁缂缃缄缅缆缇缈缉缌缎缏缑缒缓缔缕编缗缘缙缚缛缜缝缟缠缡缢缣缤缥缦缧缨缩缪缫缬缭缮缯缰缱缲缳缴缵缶缸缺罂罄罅罐网罔罕罗罘罚罟罡罢罨罩罪置罱署罴罹罾羁羊羌美羔羚羝羞羟羡群羧羯羰羲羸羹羼羽羿翁翅翊翌翎翔翕翘翟翠翡翥翦翩翮翰翱翳翻翼耀老考耄者耆耋而耍耐耒耔耕耖耗耘耙耜耠耢耥耦耧耨耩耪耱耳耵耶耷耸耻耽耿聂聃聆聊聋职聍聒联聘聚聩聪聱聿肃肄肆肇肉肋肌肓肖肘肚肛肝肟肠股肢肤肥肩肪肫肭肮肯肱育肴肷肺肼肽肾肿胀胁胂胃胄胆背胍胎胖胗胙胚胛胜胝胞胡胤胥胧胨胩胪胫胬胭胯胰胱胲胳胴胶胸胺胼能脂脆脉脊脍脎脏脐脑脒脓脔脖脘脚脞脬脯脱脲脶脸脾腆腈腊腋腌腐腑腓腔腕腙腚腠腥腧腩腭腮腰腱腴腹腺腻腼腽腾腿膀膂膈膊膏膑膘膛膜膝膦膨膪膳膺膻臀臁臂臃臆臊臌臣臧自臬臭至致臻臼臾舀舁舂舄舅舆舌舍舐舒舔舛舜舞舟舡舢舣舨航舫般舰舱舳舴舵舶舷舸船舻舾艄艇艋艘艚艟艨艮良艰色艳艴艺艽艾艿节芄芈芊芋芍芎芏芑芒芗芘芙芜芝芟芡芥芦芨芩芪芫芬芭芮芯芰花芳芴芷芸芹芽芾苁苄苇苈苊苋苌苍苎苏苑苒苓苔苕苗苘苛苜苞苟苠苡苣苤若苦苫苯英苴苷苹苻茁茂范茄茅茆茈茉茌茎茏茑茔茕茗茚茛茜茧茨茫茬茭茯茱茳茴茵茶茸茹茼荀荃荆荇草荏荐荑荒荔荚荛荜荞荟荠荡荣荤荥荦荧荨荩荪荫荬荭药荷荸荻荼荽莅莆莉莎莒莓莘莛莜莞莠莨莩莪莫莰莱莲莳莴莶获莸莹莺莼莽菀菁菅菇菊菌菏菔菖菘菜菝菟菠菡菥菩菪菰菱菲菹菽萁萃萄萋萌萍萎萏萑萘萜萝萤营萦萧萨萱萸萼落葆葑著葚葛葜葡董葩葫葬葭葱葳葵葶葸葺蒂蒇蒈蒉蒋蒌蒎蒗蒙蒜蒡蒯蒲蒴蒸蒹蒺蒽蒿蓁蓄蓉蓊蓍蓐蓑蓓蓖蓝蓟蓠蓣蓥蓦蓬蓰蓼蓿蔌蔑蔓蔗蔚蔟蔡蔫蔬蔷蔸蔹蔺蔻蔼蔽蕃蕈蕉蕊蕖蕙蕞蕤蕨蕲蕴蕹蕺蕻蕾薄薅薇薏薛薜薤薨薪薮薯薰薷薹藁藉藏藐藓藕藜藤藩藻藿蘅蘑蘖蘧蘩蘸蘼虎虏虐虑虔虚虞虢虫虬虮虱虹虺虻虼虽虾虿蚀蚁蚂蚊蚋蚌蚍蚓蚕蚜蚝蚣蚤蚧蚨蚩蚬蚯蚰蚱蚴蚶蚺蛀蛄蛆蛇蛉蛊蛋蛎蛏蛐蛑蛔蛘蛙蛛蛞蛟蛤蛩蛭蛮蛰蛱蛲蛳蛴蛸蛹蛾蜀蜂蜃蜇蜈蜉蜊蜍蜒蜓蜕蜗蜘蜚蜜蜞蜡蜢蜣蜥蜩蜮蜱蜴蜷蜻蜾蜿蝇蝈蝉蝌蝎蝓蝗蝙蝠蝣蝤蝥蝮蝰蝴蝶蝻蝼蝽蝾螂螃螅螈螋融螗螟螨螫螬螭螯螳螵螺螽蟀蟆蟊蟋蟑蟒蟛蟠蟥蟪蟮蟹蟾蠃蠊蠓蠕蠖蠡蠢蠲蠹蠼血衄衅行衍衔街衙衡衢衣补表衩衫衬衮衰衲衷衽衾衿袁袂袄袅袈袋袍袒袖袜袢袤被袭袱袼裁裂装裆裉裎裒裔裕裘裙裟裢裣裤裥裨裰裱裳裴裸裹裼裾褂褊褐褒褓褙褚褛褡褥褪褫褰褴褶襁襄襞襟襦襻西要覃覆见观规觅视觇览觉觊觋觌觎觏觐觑角觖觚觜觞解觥触觫觯觳言訇訾詈詹誉誊誓謇警譬计订讣认讥讦讧讨让讪讫训议讯记讲讳讴讵讶讷许讹论讼讽设访诀证诂诃评诅识诈诉诊诋诌词诎诏译诒诓诔试诖诗诘诙诚诛诜话诞诟诠诡询诣诤该详诧诨诩诫诬语诮误诰诱诲诳说诵请诸诹诺读诼诽课诿谀谁谂调谄谅谆谇谈谊谋谌谍谎谏谐谑谒谓谔谕谖谗谙谚谛谜谝谟谠谡谢谣谤谥谦谧谨谩谪谫谬谭谮谯谰谱谲谳谴谵谶谷豁豆豇豉豌豕豚象豢豪豫豳豸豹豺貂貅貉貊貌貔貘贝贞负贡财责贤败账货质贩贪贫贬购贮贯贰贱贲贳贴贵贶贷贸费贺贻贼贽贾贿赀赁赂赃资赅赆赇赈赉赊赋赌赍赎赏赐赓赔赖赘赙赚赛赜赝赞赠赡赢赣赤赦赧赫赭走赳赴赵赶起趁趄超越趋趑趔趟趣趱足趴趵趸趺趼趾趿跃跄跆跋跌跎跏跑跖跗跚跛距跞跟跣跤跨跪跬路跳践跷跸跹跺跻跽踅踉踊踌踏踔踝踞踟踢踣踩踪踬踮踯踱踵踹踺踽蹀蹁蹂蹄蹇蹈蹉蹊蹋蹑蹒蹙蹦蹩蹬蹭蹯蹰蹲蹴蹶蹼蹿躁躅躇躏躐躔躜躞身躬躯躲躺车轧轨轩轫转轭轮软轰轱轲轳轴轵轶轷轸轺轻轼载轾轿辁辂较辄辅辆辇辈辉辊辋辍辎辏辐辑输辔辕辖辗辘辙辚辛辜辞辟辣辨辩辫辰辱边辽达迁迂迄迅过迈迎运近迓返迕还这进远违连迟迢迤迥迦迨迩迪迫迭迮述迷迸迹追退送适逃逄逅逆选逊逋逍透逐逑递途逖逗通逛逝逞速造逡逢逦逭逮逯逵逶逸逻逼逾遁遂遄遇遍遏遐遑遒道遗遘遛遢遣遥遨遭遮遴遵遽避邀邂邃邈邋邑邓邕邗邙邛邝邡邢那邦邪邬邮邯邰邱邳邴邵邶邸邹邺邻邾郁郄郅郇郊郎郏郐郑郓郗郛郜郝郡郢郦郧部郫郭郯郴郸都郾鄂鄄鄙鄞鄢鄣鄯鄱鄹酃酆酉酊酋酌配酎酏酐酒酗酚酝酞酡酢酣酤酥酩酪酬酮酯酰酱酲酴酵酶酷酸酹酽酾酿醅醇醉醋醌醍醐醑醒醚醛醢醪醭醮醯醴醵醺采釉释里重野量金釜鉴銎銮鋈錾鍪鎏鏊鏖鐾鑫钆钇针钉钊钋钌钍钎钏钐钒钓钔钕钗钙钚钛钜钝钞钟钠钡钢钣钤钥钦钧钨钩钪钫钬钭钮钯钰钱钲钳钴钵钷钹钺钻钼钽钾钿铀铁铂铃铄铅铆铈铉铊铋铌铍铎铐铑铒铕铗铘铙铛铜铝铞铟铠铡铢铣铤铥铧铨铩铪铫铬铭铮铯铰铱铲铳铴铵银铷铸铹铺铼铽链铿销锁锂锃锄锅锆锇锈锉锊锋锌锎锏锐锑锒锓锔锕锖锗锘错锚锛锝锞锟锡锢锣锤锥锦锨锩锪锫锬锭键锯锰锱锲锴锵锶锷锸锹锺锻锾锿镀镁镂镄镅镆镇镉镊镌镍镎镏镐镑镒镓镔镖镗镘镛镜镝镞镡镢镣镤镥镦镧镨镩镪镫镬镭镯镰镱镲镳镶长门闩闪闫闭问闯闰闱闲闳间闵闶闷闸闹闺闻闼闽闾阀阁阂阃阄阅阆阈阉阊阋阌阍阎阏阐阑阒阔阕阖阗阙阚阜队阡阢阪阮阱防阳阴阵阶阻阼阽阿陀陂附际陆陇陈陉陋陌降限陔陕陛陟陡院除陧陨险陪陬陲陴陵陶陷隅隆隈隋隍随隐隔隗隘隙障隧隰隳隶隼隽难雀雁雄雅集雇雉雌雍雎雏雒雕雠雨雩雪雯雳零雷雹雾需霁霄霆震霈霉霍霎霏霓霖霜霞霪霭霰露霸霹霾青靓靖静靛非靠靡面靥革靳靴靶靼鞅鞋鞍鞑鞒鞘鞠鞣鞫鞭鞯鞲鞴韦韧韩韪韫韬韭音韵韶页顶顷顸项顺须顼顽顾顿颀颁颂颃预颅领颇颈颉颊颌颍颏颐频颓颔颖颗题颚颛颜额颞颟颠颡颢颤颥颦颧风飑飒飓飕飘飙飚飞食飧飨餍餐餮饔饕饥饧饨饩饪饫饬饭饮饯饰饱饲饴饵饶饷饺饼饽饿馀馁馄馅馆馇馈馊馋馍馏馐馑馒馓馔馕首馗馘香馥馨马驭驮驯驰驱驳驴驵驶驷驸驹驺驻驼驽驾驿骀骁骂骄骅骆骇骈骊骋验骏骐骑骒骓骖骗骘骚骛骜骝骞骟骠骡骢骣骤骥骧骨骰骶骷骸骺骼髀髁髂髅髋髌髑髓高髡髦髫髭髯髹髻鬃鬈鬏鬓鬟鬣鬯鬲鬻鬼魁魂魃魄魅魇魈魉魍魏魑魔鱼鱿鲁鲂鲅鲆鲇鲈鲋鲍鲎鲐鲑鲔鲚鲛鲜鲞鲟鲠鲡鲢鲣鲤鲥鲦鲧鲨鲩鲫鲭鲮鲰鲱鲲鲳鲴鲵鲷鲸鲺鲻鲼鲽鳃鳄鳅鳆鳇鳊鳌鳍鳎鳏鳐鳓鳔鳕鳖鳗鳘鳙鳜鳝鳞鳟鳢鸟鸠鸡鸢鸣鸥鸦鸨鸩鸪鸫鸬鸭鸯鸱鸲鸳鸵鸶鸷鸸鸹鸺鸽鸾鸿鹁鹂鹃鹄鹅鹆鹇鹈鹉鹊鹋鹌鹎鹏鹑鹕鹗鹘鹚鹛鹜鹞鹣鹤鹦鹧鹨鹩鹪鹫鹬鹭鹰鹱鹳鹿麂麇麈麋麒麓麝麟麦麸麻麽麾黄黉黍黎黏黑黔默黛黜黝黟黠黢黥黧黩黯黹黻黼黾鼋鼍鼎鼐鼓鼗鼙鼠鼢鼬鼯鼷鼹鼻鼾齐齑齿龀龃龄龅龆龇龈龉龊龋龌龙龚龛龟龠',
+ korean:
+ ' !"#$%&\'()*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~가각간갇갈감갑값강갖같갚갛개객걀거걱건걷걸검겁것겉게겨격겪견결겹경곁계고곡곤곧골곰곱곳공과관광괜괴굉교구국군굳굴굵굶굽궁권귀규균그극근글긁금급긋긍기긴길김깅깊까깎깐깔깜깝깥깨꺼꺾껍껏껑께껴꼬꼭꼴꼼꼽꽂꽃꽉꽤꾸꿀꿈뀌끄끈끊끌끓끔끗끝끼낌나낙낚난날낡남납낫낭낮낯낱낳내냄냉냐냥너넉널넓넘넣네넥넷녀녁년념녕노녹논놀놈농높놓놔뇌뇨누눈눕뉘뉴늄느늑는늘늙능늦늬니닐님다닥닦단닫달닭닮담답닷당닿대댁댐더덕던덜덤덥덧덩덮데델도독돈돌돕동돼되된두둑둘둠둡둥뒤뒷드득든듣들듬듭듯등디딩딪따딱딴딸땀땅때땜떠떡떤떨떻떼또똑뚜뚫뚱뛰뜨뜩뜯뜰뜻띄라락란람랍랑랗래랜램랫략량러럭런럴럼럽럿렁렇레렉렌려력련렬렵령례로록론롬롭롯료루룩룹룻뤄류륙률륭르른름릇릎리릭린림립릿마막만많말맑맘맙맛망맞맡맣매맥맨맵맺머먹먼멀멈멋멍멎메멘멩며면멸명몇모목몰몸몹못몽묘무묵묶문묻물뭄뭇뭐뭣므미민믿밀밉밌및밑바박밖반받발밝밟밤밥방밭배백뱀뱃뱉버번벌범법벗베벤벼벽변별볍병볕보복볶본볼봄봇봉뵈뵙부북분불붉붐붓붕붙뷰브블비빌빗빚빛빠빨빵빼뺨뻐뻔뻗뼈뽑뿌뿐쁘쁨사삭산살삶삼상새색샌생서석섞선설섬섭섯성세센셈셋션소속손솔솜솟송솥쇄쇠쇼수숙순술숨숫숲쉬쉽슈스슨슬슴습슷승시식신싣실싫심십싱싶싸싹쌀쌍쌓써썩썰썹쎄쏘쏟쑤쓰쓸씀씌씨씩씬씹씻아악안앉않알앓암압앗앙앞애액야약얇양얗얘어억언얹얻얼엄업없엇엉엌엎에엔엘여역연열엷염엽엿영옆예옛오옥온올옮옳옷와완왕왜왠외왼요욕용우욱운울움웃웅워원월웨웬위윗유육율으윽은을음응의이익인일읽잃임입잇있잊잎자작잔잖잘잠잡장잦재쟁저적전절젊점접젓정젖제젠젯져조족존졸좀좁종좋좌죄주죽준줄줌줍중쥐즈즉즌즐즘증지직진질짐집짓징짙짚짜짝짧째쨌쩌쩍쩐쪽쫓쭈쭉찌찍찢차착찬찮찰참창찾채책챔챙처척천철첫청체쳐초촉촌총촬최추축춘출춤춥춧충취츠측츰층치칙친칠침칭카칸칼캐캠커컨컬컴컵컷켓켜코콜콤콩쾌쿠퀴크큰클큼키킬타탁탄탈탑탓탕태택탤터턱털텅테텍텔템토톤톱통퇴투툼퉁튀튜트특튼튿틀틈티틱팀팅파팎판팔패팩팬퍼퍽페펴편펼평폐포폭표푸푹풀품풍퓨프플픔피픽필핏핑하학한할함합항해핵핸햄햇행향허헌험헤헬혀현혈협형혜호혹혼홀홍화확환활황회획횟효후훈훌훔훨휘휴흉흐흑흔흘흙흡흥흩희흰히힘',
+ telugu:
+ '0123456789!"#$%&\'()*+,-./:;<=>?@[\\]^_`{|}~ abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZఁంఃఅఆఇఈఉఊఋఌఎఏఐఒఓఔకఖగఘఙచఛజఝఞటఠడఢణతథదధనపఫబభమయరఱలళవశషసహాిీుూృౄెేైొోౌ్ౠౡౢౣ',
+ kannada:
+ '0123456789!"#$%&\'()*+,-./:;<=>?@[\\]^_`{|}~ abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZಂಃಅಆಇಈಉಊಋಎಏಐಒಓಔಕಖಗಘಙಚಛಜಝಞಟಠಡಢಣತಥದಧನಪಫಬಭಮಯರಲಳವಶಷಸಹಾಿೀುೂೃೆೇೈೊೋೌ್೦೧೨೩೪೫೬೭೮೯',
+};
+
+export const PPOCR_SYMBOLS =
+ '!"#$%&\'()*+,-./:;<=>?@[\\]^_`{|}~0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz¢£¤¥¦§¨©ª«¬®¯°±²³´µ¶·¸¹º»¼½¾¿ÀÁÂÃÄÅÆÇÈÉÊËÌÍÎÏÐÑÒÓÔÕÖרÙÚÛÜÝÞßàáâãäåæçèéêëìíîïðñòóôõö÷øùúûüýþÿĀāĂ㥹ĆćĈĉĊċČčĎďĐđĒēĔĕĖėĘęĚěĜĝĞğĠġĢģĤĥĦħĨĩĪīĬĭĮįİıIJijĴĵĶķĸĹĺĻļĽľĿŀŁłŃńŅņŇňʼnŊŋŌōŎŏŐőŒœŔŕŖŗŘřŚśŜŝŞşŠšŢţŤťŦŧŨũŪūŬŭŮůŰűŲųŴŵŶŷŸŹźŻżŽžſƀƁƂƃƄƅƆƇƈƉƊƋƌƍƎƏƐƑƒƓƔƕƖƗƘƙƚƛƜƝƞƟƠơƢƣƤƥƦƧƨƩƪƫƬƭƮƯưƱƲƳƴƵƶƷƸƹƺƻƼƽƾƿǀǁǂǃDŽDždžLJLjljNJNjnjǍǎǏǐǑǒǓǔǕǖǗǘǙǚǛǜǝǞǟǠǡǢǣǤǥǦǧǨǩǪǫǬǭǮǯǰDZDzdzǴǵǶǷǸǹǺǻǼǽǾǿȀȁȂȃȄȅȆȇȈȉȊȋȌȍȎȏȐȑȒȓȔȕȖȗȘșȚțȜȝȞȟȠȡȢȣȤȥȦȧȨȩȪȫȬȭȮȯȰȱȲȳȴȵȶȷȸȹȺȻȼȽȾȿɀɁɂɃɄɅɆɇɈɉɊɋɌɍɎɏɐɑɒɓɔɕɖɗɘəɚɛɜɝɞɟɠɡɢɣɤɥɦɧɨɩɪɫɬɭɮɯɰɱɲɳɴɵɶɷɸɹɺɻɼɽɾɿʀʁʂʃʄʅʆʇʈʉʊʋʌʍʎʏʐʑʒʓʔʕʖʗʘʙʚʛʜʝʞʟʠʡʢʣʤʥʦʧʨʩʪʫʬʭʮʯΑΒΓΔΕΖΗΘΙΚΛΜΝΞΟΠΡΣΤΥΦΧΨΩαβγδεζηθικλμνξοπρστυφχψωϐϑϒϕϖϝϞϟϠϡϢϣϤϥϦϧϨϩϪϫϬϭϮϯϰϱϴϵẠồ–—―‖‘’“”†‡•‥…‰′″※⁎⁰⁴⁵⁶⁷⁸⁹⁺⁻⁼⁽⁾ⁿ₀₁₂₃₄₅₆₇₈₉₊₋₌₍₎ₒ₠₡₢₣₤₥₦₧₨₩₪₫€₭₮₯₰₱₲₳₴₵₶₷₸₹₺₻₼₽₾₿℃℉ℎℏℑ℘ℜ™℧Åℵℶℷℸℹ⅀ⅠⅡⅢⅣⅤⅥⅦⅧⅨⅩⅪⅫⅰⅱⅲⅳⅴⅵⅶⅷⅸⅹⅺⅻⅼↀↁↂ←↑→↓↔↕↖↗↘↙↚↛↜↝↞↟↠↡↢↣↤↥↦↧↨↩↪↫↬↭↮↯↰↱↲↳↴↵↶↷↸↹↺↻↼↽↾↿⇀⇁⇂⇃⇄⇅⇆⇇⇈⇉⇊⇋⇌⇍⇎⇏⇐⇑⇒⇓⇔⇕⇖⇗⇘⇙⇚⇛⇜⇝⇞⇟⇠⇡⇢⇣⇤⇥⇦⇧⇨⇩⇪⇫⇬⇭⇮⇯⇰⇱⇲⇳⇴⇵⇶⇷⇸⇹⇺⇻⇼⇽⇾⇿∀∁∂∃∄∅∆∇∈∉∋∏∑−∓∕∖∙√∛∜∝∞∟∠∡∢∥∧∨∩∪∫∬∭∮∯∰∱∲∳∴∵∶∷∸∹∺∻∼∽∾∿≀≁≂≃≄≅≆≇≈≉≊≋≌≍≎≏≐≑≒≓≔≕≖≗≘≙≚≛≜≝≞≟≠≡≢≣≤≥≦≧≨≩≪≫≬≭≮≯≰≱≲≳≴≵≶≷≸≹≺≻≼≽≾≿⊀⊁⊂⊃⊄⊅⊆⊇⊈⊉⊊⊋⊌⊍⊎⊏⊐⊑⊒⊓⊔⊕⊖⊗⊘⊙⊚⊛⊜⊝⊞⊟⊠⊡⊢⊣⊤⊥⊦⊧⊨⊩⊪⊫⊬⊭⊮⊯⊰⊱⊲⊳⊴⊵⊶⊷⊸⊹⊺⊻⊼⊽⊾⊿⋅⌀⌃⌘⌚⌛⌤⌥⌦⌧⌨〉⌫⌬⌭⌮⌯⍵⍺⎆⎇⎈⎉⎊⎋⎌⎍⎎⎏⎐⎑⎒⎓⎔⎕⎖⎗⎘⎙⎚⎛⎜⎝⎞⎟⎠⎡⎢⎣⎤⎥⎦⎧⎨⎩⎪⎫⎬⎭⎮⎯⎰⎱⎲⎳⎴⎵⎶⎷⎸⎹⎺⎻⎼⎽⎾⎿⏀⏁⏂⏃⏄⏅⏆⏇⏈⏉⏊⏋⏌⏍⏎⏏⏐⏑⏒⏓⏔⏕⏖⏗⏘⏙⏚⏛⏜⏝⏞⏟⏠⏡⏢⏣⏤⏥⏦⏧⏨⏩⏪⏫⏬⏭⏮⏯⏰⏱⏲⏳⏴⏵⏶⏷⏸⏹⏺⏻⏼⏽⏾⏿①②③④⑤⑥⑦⑧⑨⑩⑪⑫⑬⑭⑮⑯⑰⑱⑲⑳Ⓜ━╆╳▁█■□▪▫▬▲△▶▷▼▽◀◆◇◉◊○◎●◐◥◻◼◽◾☀☁☂☃☄★☆☇☈☉☊☋☌☍☎☏☐☑☒☓☔☕☖☗☘☙☚☛☜☝☞☟☠☡☢☣☤☥☦☧☨☩☪☫☬☭☮☯☰☱☲☳☴☵☶☷☸☹☺☻☼☽☾☿♀♁♂♃♄♅♆♇♈♉♊♋♌♍♎♏♐♑♒♓♔♕♖♗♘♙♚♛♜♝♞♟♠♡♢♣♤♥♦♧♨♩♪♫♬♭♮♯♰♱♲♳♴♵♶♷♸♹♺♻♼♽♾♿⚀⚁⚂⚃⚄⚅⚆⚇⚈⚉⚊⚋⚌⚍⚎⚏⚐⚑⚒⚓⚔⚕⚖⚗⚘⚙⚚⚛⚜⚝⚞⚟⚠⚡⚢⚣⚤⚥⚦⚧⚨⚩⚪⚫⚬⚭⚮⚯⚰⚱⚲⚳⚴⚵⚶⚷⚸⚹⚺⚻⚼⚽⚾⚿⛀⛁⛂⛃⛄⛅⛆⛇⛈⛉⛊⛋⛌⛍⛎⛏⛐⛑⛒⛓⛔⛕⛖⛗⛘⛙⛚⛛⛜⛝⛞⛠⛡⛢⛣⛤⛥⛦⛧⛨⛩⛪⛫⛬⛭⛮⛯⛲⛳⛵⛶⛺⛽⛾⛿✂✅✆✇✈✉✊✋✌✍✎✏✐✑✒✓✔✕✖✘✙✚✛✜✝✞✟✠✡✢✣✤✥✦✧✨✩✪✫✬✭✮✯✰✱✲✳✴✵✶✷✸✹✺✻✼✽✾✿❀❁❂❃❄❅❆❇❈❉❊❋❌❍❎❏❐❑❒❓❔❕❖❗❘❙❚❛❜❝❞❡❢❣❤❥❦❧❨❩❪❫❬❭❮❯❰❱❲❳❴❵❶❷❸❹❺❻❼❽❾❿➀➁➂➃➄➅➆➇➈➉➊➋➌➍➎➏➐➑➒➓➔➕➖➗➘➙➚➛➜➝➞➟➠➡➢➣➤➥➦➧➨➩➪➫➬➭➮➯➰➱➲➳➴➵➶➷➸➹➺➻➼➽➾➿⟶⟹⤴⤵⬅⬆⬇⬛⬜⭐⭕ 、。々〆〇〈〉《》「」『』【】〒〔〕〖〗〜〰〵〽ぁあぃいぅうぇえぉおかがきぎくぐけげこごさざしじすずせぜそぞただちぢっつづてでとどなにぬねのはばぱひびぴふぶぷへべぺほぼぽまみむめもゃやゅゆょよらりるれろゎわゑをんゕゖゝゞァアィイゥウェエォオカガキギクグケゲコゴサザシジスズセゼソゾタダチヂッツヅテデトドナニヌネノハバパヒビピフブプヘベペホボポマミムメモャヤュユョヨラリルレロヮワヰヲンヴヵヶヷヸヹヺ・ーヽヾヿ㈱㊗㊙㐂㐱㑇㑊㑳㑺㒺㕁㕑㕥㕮㘎㘭㙍㙘㙟㙦㚷㛃㛚㛹㝛㝠㝡㟁㟃㠇㠓㠣㠯㠶㡌㢘㤘㤙㥄㥫㥮㧐㧑㧟㧱㨗㨪㩗㩦㩳㪚㪟㫰㬉㬊㬎㬚㬪㭎㭕㮣㮾㰀㳄㳇㳒㳘㳚㴔㴪㴱㵐㶲㸃㸆㸌㺄㻬㼝㽏㽞㿠䁖䂮䃅䃎䃮䅟䈰䊀䌹䎃䎖䏝䏡䏲䐃䓖䓛䓣䓨䓫䓬䖝䗖䗛䗪䗬䗴䘏䘑䘚䜣䝉䝔䝙䠀䠶䢺䢼䣘䥽䦃䮄䰟䰾䲁䲟䲠䲢䴉䴓䴔䴕䴖䴗䴘䴙䶊䶮一丁七丄万丈三上下丌不与丏丐丑专且丕世丘丙业丛东丝丞丟両丢两严並丧个丫中丮丰丱串临丸丹为主丼丽举丿乂乃久么义之乌乍乎乏乐乒乓乔乖乗乘乙乜九乞也习乡书乩乭买乱乳乸乹乾亀亂了予争亊事二亍于亏云互亓五井亘亙亚些亜亞亟亡亢交亥亦产亨亩享京亭亮亯亰亱亲亳亵亶亷亸亹人亾亿什仁仂仃仄仅仆仇仉今介仍从仏仑仓仔仕他仗付仙仛仜仝仞仟仡代令以仨仩仪仫们仮仰仱仲仳仵件价仺任仼份仿伀企伃伈伉伊伋伍伎伏伐休伔伕众优伙会伛伝伞伟传伢伣伤伥伦伧伪伫伬伭伯估伱伲伴伶伷伸伺伻似伽伾佀佁佃但佇佈佉佋佌位低住佐佑佒体佔何佖佗佘余佚佛作佝佞佟你佢佣佤佥佧佩佪佬佯佰佳佴併佶佷佸佹佺佻佼佽佾使侀侁侂侃侄侅來侇侈侉侊例侍侏侐侑侔侖侗侘侚供侜依侞侠価侣侥侦侧侨侩侪侬侮侯侲侳侴侵侶侷侹侻便俁係促俄俅俇俉俊俋俌俍俎俏俐俑俓俔俗俘俙俚俛俜保俞俟俠信俣俤俦俨俩俪俫俬俭修俯俱俳俴俵俶俷俸俺俽俾倀倃倅倆倇倈倉個倌倍倏倐們倒倓倔倕倖倗倘候倚倛倜倞借倠倡倢倣値倥倦倧倨倩倪倫倬倭倮倰倳倴倶倷倸倹债倻值倾偁偃偅假偈偉偊偋偌偍偎偏偐偓偕偘做偛停偝偞偟偠偡偢健偨偩偪偫偬偭偯偰偲側偵偶偷偸偺偻偽偾偿傀傂傃傅傈傉傋傌傍傎傑傒傔傕傖傘備傚傛傜傝傞傢傣傥傧储傩催傭傮傯傰傱傲傳傴債傷傺傻傽傾傿僁僂僄僅僆僇僈僉僊僋働僎像僑僓僔僕僖僗僙僚僛僜僝僞僡僤僥僦僧僨僩僪僬僭僮僰僱僳僴僵僶僸價僻僽僾僿儀儁儂儃億儆儇儈儉儋儌儐儑儒儓儔儕儗儘儚儛儜儞償儠儡儢儤儥儦優儫儰儱儲儳儴儵儷儸儹儺儻儼儽儿兀允元兄充兆兇先光克兌免兎児兑兒兔兕兖兗兙党兛兜兝兞兠兡兢兣入內全兩兪八公六兮兰共关兴兵其具典兹养兼兽兿冀冁冂冄内円冇冈冉冊册再冏冐冑冒冓冔冕冗冘写冚军农冞冠冢冣冤冥冧冨冪冬冮冯冰冱冲决冴况冶冷冺冻冼冽净凃凄凅准凇凈凉凊凋凌凍减凑凔凖凘凛凜凝凞几凡凢凤処凪凫凬凭凯凰凱凳凵凶凸凹出击凼函凿刀刁刂刃刄分切刈刉刊刌刍刎刑划刓刖列刘则刚创刜初刞删判別刦刧刨利刪别刬刭刮到刱刲刳刵制刷券刹刺刻刼刽刿剀剁剂剃剄剅剆則剉削剋剌前剎剏剐剑剒剔剕剖剗剚剛剜剝剞剟剡剣剤剥剧剩剪剫剬剭剮副剰割剳剴創剷剸剹剺剻剼剽剿劀劁劂劃劄劇劈劉劊劋劌劍劐劑劓劔劖劗劘劙力劝办功加务劢劣劦动助努劫劬劭励劲劳労劵効劼劾势勁勃勅勇勉勋勌勍勐勑勒勔動勖勗勘務勚勛勝勞募勢勣勤勦勧勩勰勱勲勳勴勵勷勸勺勻勾勿匀匁匂匃匄包匆匈匊匋匍匏匐匕化北匙匜匝匟匠匡匢匣匦匪匭匮匯匰匱匲匴匷匸匹区医匼匽匾匿區十千卅升午卉半卋卌卍华协卐卑卒卓協单卖南単博卜卞卟占卡卢卣卤卦卧卫卬卮卯印危卲即却卵卷卸卹卺卻卼卽卿厀厂厄厅历厉压厌厍厎厒厓厔厕厖厗厘厙厚厜厝厞原厠厢厣厤厥厦厨厩厬厭厮厰厲厳厴厹去厾县叁参參叄叆叇又叉及友双反収发叔叕取受变叙叚叛叟叠叡叢口古句另叨叩只叫召叭叮可台叱史右叵叶号司叹叻叼叽吁吃各吆合吉吊吋同名后吏吐向吒吓吔吕吖吗吚君吝吞吟吠吡吣吥否吧吨吩吪含听吭吮启吰吱吲吳吴吵吶吷吸吹吻吼吽吾呀呂呃呆呇呈呉告呋呌呎呐呑呒呓呔呕呖呗员呙呛呜呢呣呤呥呦周呪呫呬呰呱呲味呴呵呶呷呸呺呻呼命呾呿咀咁咂咄咅咆咇咈咉咊咋和咍咎咏咐咑咒咔咕咖咗咘咙咚咛咝咟咠咡咢咣咤咥咦咧咨咩咪咫咬咭咮咯咱咲咳咴咶咷咸咺咻咼咽咾咿哀品哂哃哄哅哆哇哈哉哌响哎哏哐哑哒哓哔哕哖哗哙哚哝哞哟員哢哤哥哦哧哨哩哪哫哭哮哱哲哳哶哺哼哽哿唁唃唄唅唆唇唈唉唌唏唐唑唒唔唕唖唗唘唚唛唝唠唢唣唤唦唧唪唫唬唭售唯唰唱唲唳唴唵唶唷唸唻唼唾唿啁啃啄啅商啉啊啍啎問啐啑啒啓啕啖啗啚啜啞啟啡啢啣啤啥啦啧啪啫啬啭啮啯啰啱啲啴啵啶啷啸啻啼啽啾喀喁喂喃善喆喇喈喉喊喋喌喍喏喑喒喓喔喘喙喚喜喝喟喢喣喤喥喦喧喨喩喪喫喬喭單喰喱喲喳喵営喷喹喻喼喽喾喿嗀嗁嗃嗄嗅嗆嗇嗈嗉嗊嗋嗌嗍嗎嗏嗐嗑嗒嗓嗔嗕嗖嗚嗛嗜嗝嗞嗟嗡嗢嗣嗤嗥嗦嗧嗨嗩嗪嗫嗬嗮嗯嗲嗳嗵嗶嗷嗹嗺嗼嗽嗾嗿嘀嘁嘂嘄嘅嘆嘈嘉嘌嘍嘎嘏嘐嘑嘒嘓嘔嘕嘖嘗嘘嘚嘛嘜嘝嘞嘟嘠嘡嘢嘣嘤嘥嘧嘩嘬嘭嘮嘯嘰嘱嘲嘳嘴嘵嘶嘷嘸嘹嘻嘽嘾嘿噀噁噂噅噆噇噈噉噊噌噍噎噏噓噔噗噘噙噚噛噜噝噞噠噢噣噤噥噦器噩噪噫噬噭噮噯噰噱噲噳噴噶噸噹噺噻噼嚀嚃嚄嚅嚆嚇嚈嚌嚍嚎嚏嚐嚒嚓嚕嚗嚘嚙嚚嚜嚞嚟嚢嚣嚤嚥嚦嚧嚨嚩嚪嚫嚬嚭嚮嚯嚲嚳嚴嚵嚶嚷嚼嚽嚾嚿囀囁囂囃囅囆囈囉囊囋囌囍囑囒囓囔囗囙囚四囝回囟因囡团団囤囧囪囫园囮囯困囱囲図围囵囶囷囹固国图囿圀圁圂圃圄圅圆圇圈圉圊國圌圍圏圐園圓圖圗團圙圜圞土圠圢圣圧在圩圪圫圬圭圮圯地圲圳圹场圻圾址坁坂坅均坉坊坋坌坍坎坏坐坑坒坖块坚坛坜坝坞坟坠坡坣坤坥坦坨坩坪坫坬坭坮坯坰坱坲坳坴坵坶坷坻坼坽坿垀垂垃垄垆垈型垌垍垎垏垒垓垔垕垗垘垙垚垛垜垝垞垟垠垡垢垣垤垦垧垩垫垭垮垯垰垱垲垴垵垸垺垻垼垽垾垿埂埃埅埆埇埈埋埌城埏埒埔埕埗埘埙埚埜埝域埠埡埢埤埧埨埪埫埭埮埯埳埴埵埶執埸培基埻埼埽堀堂堃堅堆堇堈堉堊堋堌堍堎堐堑堔堕堖堘堙堝堞堠堡堣堤堥堦堧堨堩堪堮堯堰報堲場堵堷堺堼堽堾堿塀塁塄塅塆塈塉塊塋塌塍塏塑塒塓塔塕塗塘塙塚塜塝塞塟塡塢塤塥塨塩填塬塭塯塰塱塲塴塵塸塹塺塼塽塾塿墀墁境墅墆墈墉墊墋墎墐墒墓墕墖増墘墙墚墜墝增墟墠墡墣墦墨墩墪墫墬墮墯墱墳墶墺墻墼墽墾壁壄壅壆壇壈壊壋壌壎壐壑壓壔壕壖壘壙壚壛壝壞壟壠壢壤壩壪士壬壮壯声壱売壳壴壶壷壸壹壺壻壼壽壾壿夀夃处夆备変夊夋夌复夎夏夐夒夔夕外夗夘夙多夜够夠夢夤夥大天太夫夬夭央夯失头夷夸夹夺夼夾奀奁奂奄奅奇奈奉奊奋奎奏奐契奓奔奕奖套奘奚奠奡奢奣奥奧奨奩奪奫奬奭奮奰奲女奴奶奸她奼好妀妁如妃妄妆妇妈妊妍妎妏妐妑妒妓妖妗妘妙妝妞妠妢妣妤妥妦妧妨妩妪妫妬妭妮妯妲妳妵妷妸妹妺妻妾姀姁姃姆姈姉姊始姌姍姎姏姐姑姒姓委姗姘姙姚姜姝姞姡姣姤姥姦姨姪姫姬姮姱姵姶姸姹姺姻姼姽姿娀威娃娄娅娆娇娈娉娋娌娍娎娑娓娖娘娙娛娜娟娠娣娥娩娫娭娯娱娲娳娴娵娶娷娸娼娽娿婀婁婆婉婊婌婍婐婑婓婕婗婘婚婞婟婠婢婣婤婥婦婧婪婫婬婭婯婳婴婵婶婷婸婺婻婼婿媂媃媄媆媊媐媒媓媔媕媖媗媚媛媜媞媟媢媥媦媧媪媬媭媮媯媰媱媲媳媵媸媺媻媼媽媾媿嫁嫂嫄嫇嫈嫉嫋嫌嫐嫒嫔嫕嫖嫗嫘嫚嫛嫜嫠嫡嫢嫣嫥嫦嫩嫪嫫嫭嫮嫰嫱嫲嫳嫴嫵嫶嫷嫺嫻嫽嫿嬀嬃嬅嬈嬉嬋嬌嬎嬐嬓嬖嬗嬙嬛嬝嬡嬢嬤嬥嬨嬪嬬嬭嬰嬲嬴嬷嬸嬽嬾嬿孀孁孃孅孆孈孋孌子孑孓孔孕孖字存孙孚孛孜孝孟孢季孤孥学孩孪孫孬孮孰孱孲孳孵孷學孺孻孼孽孿宁宂它宄宅宇守安宋完宍宎宏宓宕宗官宙定宛宜宝实実宠审客宣室宥宦宧宨宪宫宬宮宰害宴宵家宸容宽宾宿寀寁寂寃寄寅密寇寈寊寋富寍寐寑寒寓寔寕寖寗寘寙寛寜寝寞察寠寡寢寤寥實寧寨審寪寫寬寮寯寰寱寳寵寶寸对寺寻导対寿封専尃射尅将將專尉尊尋尌對導小尐少尒尓尔尕尖尗尘尙尚尜尝尟尢尤尥尧尨尪尬尭尰就尳尴尷尸尹尺尻尼尽尾尿局屁层屃屄居屆屇屈屉届屋屌屍屎屏屐屑屓屔展屖屘屙屚屜屝属屠屡屢屣層履屦屧屨屩屬屭屮屯山屴屹屺屻屼屾屿岀岁岂岅岈岉岊岋岌岍岏岐岑岒岔岖岗岘岙岚岛岜岝岞岟岠岡岢岣岤岧岨岩岪岫岬岭岮岯岰岱岳岵岷岸岻岽岿峁峂峃峄峅峇峉峊峋峌峍峐峒峗峘峙峚峛峝峞峠峡峣峤峥峦峧峨峩峪峬峭峮峯峰峱峴島峷峻峼峽峿崀崁崂崃崄崆崇崈崋崌崍崎崏崐崑崒崔崖崗崘崙崚崛崝崞崟崠崡崢崣崤崥崦崧崨崩崬崭崮崰崱崳崴崵崶崷崺崽崾崿嵁嵂嵃嵅嵇嵊嵋嵌嵎嵐嵑嵒嵕嵖嵗嵘嵙嵚嵛嵜嵝嵞嵣嵥嵨嵩嵫嵬嵮嵯嵰嵱嵲嵴嵷嵺嵻嵽嵾嵿嶀嶁嶂嶃嶄嶅嶆嶇嶈嶉嶊嶋嶌嶍嶒嶓嶔嶕嶗嶙嶚嶜嶝嶞嶟嶠嶡嶢嶦嶧嶨嶩嶪嶬嶭嶮嶰嶱嶲嶴嶵嶷嶸嶺嶼嶽巀巂巃巄巅巆巇巉巋巌巍巎巏巑巒巔巕巖巗巘巛川州巟巡巢巣工左巧巨巩巫差巯巰己已巳巴巵巶巷巹巻巽巾巿帀币市布帅帆师帊希帏帐帑帔帕帖帗帘帙帚帛帜帝帟帠帡帢帣帤帥带帧帨帩師帬席帮帯帰帱帳帴帶帷常帻帼帽帾幀幂幃幄幅幇幌幎幏幑幓幔幕幖幗幘幙幚幛幝幞幟幠幡幢幣幦幧幨幩幪幫幬幭幰干平年幵并幷幸幹幺幻幼幽幾广庀庁庂広庄庆庇庈庉床庋庌序庐庑库应底庖店庙庚庛府庞废庠庢庣庤庥度座庨庪庫庬庭庮庰庱庲庳庴庵庶康庸庹庻庼庽庾廁廂廃廄廅廆廇廈廉廊廋廌廍廎廐廑廒廓廔廕廖廗廘廙廚廛廜廝廞廟廠廡廢廣廥廦廧廨廩廪廬廮廯廰廱廳廵延廷廸廹建廻廼廾廿开弁异弃弄弅弆弇弈弉弊弋弌弍式弐弑弒弓弔引弖弗弘弚弛弝弟张弢弣弤弥弦弧弨弩弭弮弯弱弳張弶強弸弹强弼弾彀彃彄彅彆彈彉彊彋彌彎彏彐归当彔录彖彗彘彙彜彝彞彟彠彡形彣彤彥彦彧彩彪彫彬彭彯彰影彳彴彷彸役彻彼彾彿往征徂徃径待徇很徉徊律後徐徑徒従徕得徘徙徛徜從徠御徥徦徧徨復循徫徬徭微徯徲徳徴徵德徸徹徼徽徾徿忀忁心必忆忉忌忍忏忐忑忒忔忕忖志忘忙応忝忞忠忡忣忤忥忧忨忪快忬忭忮忯忱忳念忷忸忺忻忼忽忾忿怀态怂怃怄怅怆怊怋怍怎怏怐怒怓怔怕怖怗怙怚怛怜思怞怠怡怢急怦性怨怩怪怫怬怭怮怯怱怲怳怴怵怷怹总怼怿恀恁恂恃恅恆恇恉恊恋恌恍恐恒恓恔恕恙恚恛恝恞恟恠恡恢恣恤恥恧恨恩恪恫恬恭息恰恲恳恵恶恸恹恺恻恼恽恿悀悁悃悄悅悆悇悈悉悊悌悍悐悒悔悕悖悗悚悛悝悞悟悠悢患悤悦悧您悩悪悫悬悭悯悰悱悲悳悴悵悶悷悸悹悺悻悼悽悾惄情惆惇惈惉惊惋惌惎惏惑惓惔惕惘惙惚惛惜惝惟惠惡惢惣惤惥惦惧惨惩惪惫惬惭惮惯惰惱惲想惴惵惶惷惸惹惺惻惼惾愀愁愃愅愆愈愉愊愍愎意愐愒愓愔愕愖愚愛愜感愠愣愤愦愧愨愫愬愭愮愯愲愴愶愷愼愽愾愿慁慂慄慅慆慇慈慉慊態慌慍慎慑慒慓慔慕慘慚慛慜慝慞慟慢慣慤慥慧慨慪慫慬慭慮慰慱慲慳慴慵慶慷慹慺慼慽慾憀憂憃憇憉憊憋憍憎憐憑憒憓憔憕憖憙憚憛憝憟憡憢憤憧憨憩憪憫憬憭憮憯憰憱憲憴憵憶憷憸憺憻憼憾憿懁懂懃懅懆懇懈應懊懋懌懍懐懑懒懔懘懞懟懠懣懤懥懦懧懨懪懫懭懮懰懱懲懵懶懷懸懺懻懼懽懾懿戀戁戃戄戆戇戈戉戊戋戌戍戎戏成我戒戔戕或戗战戙戚戛戞戟戠戡戢戣戤戥戦戧戩截戬戭戮戯戰戱戲戳戴戶户戸戹戺戻戼戽戾房所扁扂扃扅扆扇扈扉扊手才扎扐扑扒打扔払托扙扛扜扞扡扢扣扤扥扦执扩扪扫扬扭扮扯扰扱扳扶批扺扻扼扽找承技抃抄抆抇抉把抌抎抏抑抒抓抔投抖抗折抚抛抜抝択抟抠抡抢护报抦抨抪披抬抭抮抱抴抵抶抹抻押抽抾抿拂拃拄担拆拇拈拉拊拋拌拍拎拏拐拑拒拓拔拕拖拗拘拙拚招拜拝拟拠拡拢拣拤拥拦拧拨择拫括拭拮拯拱拲拳拴拵拶拷拸拹拺拼拽拾拿挀持挂挃指挈按挋挌挍挎挏挐挑挓挖挙挚挛挝挞挟挠挡挣挤挥挦挨挩挪挫挬振挱挲挴挵挶挹挺挻挼挽挾挿捁捂捃捄捅捆捉捊捋捌捍捎捏捐捒捔捕捖捗捘捜捞损捡换捣捥捦捧捨捩捫捭据捯捱捲捵捶捷捺捻捼捽掀掂掃掄掇授掉掊掌掎掏掐排掔掖掗掘掙掛掜掝掞掟掠採探掣掤接控推掩措掫掬掭掮掯掰掱掲掳掴掷掸掺掻掼掽掾揀揃揄揅揆揉揊揍揎描提揑插揔揕揖揘揚換揜揝揟揠握揣揤揥揨揩揪揫揭揮揯揱揲揳援揵揶揷揸揹揺揽揿搀搁搂搅搆搉搊搋搌損搏搐搒搓搔搕搖搗搘搚搛搜搞搟搠搡搢搣搤搥搦搧搨搪搫搬搭搯搰搳搴搵搶搷搹携搽搾摀摁摂摃摄摅摆摇摈摊摋摍摎摏摐摑摒摓摔摘摛摜摝摞摟摠摡摦摧摩摭摮摯摰摲摳摴摵摶摸摹摺摻摽摿撂撃撄撅撇撈撊撋撌撏撐撑撒撓撕撖撗撙撚撜撝撞撟撠撢撣撤撥撦撩撫撬播撮撰撲撳撵撷撸撹撺撻撼撽撾撿擀擁擂擄擅擇擉擊擋操擎擏擐擒擓擔擕擖擗擘據擞擠擡擢擣擤擥擦擧擨擩擫擬擭擯擰擱擲擴擷擸擺擻擼擽擾擿攀攄攆攇攉攌攍攏攐攒攓攔攕攖攗攘攙攛攜攝攞攠攡攢攣攤攥攦攩攪攫攬攭攮支攲攳攴收攷攸改攻攽放政敁敂故敆效敉敊敌敍敎敏救敓敔敕敖敗敘教敛敜敝敞敟敢散敤敦敧敩敪敫敬敭敯数敲敳整敵敶敷數敹敺敻敾敿斀斁斂斃斄文斉斋斌斎斐斑斒斓斔斕斖斗料斛斜斝斞斟斠斡斤斥斧斨斩斪斫斬断斮斯新斲斵斶斷方斻於施斿旁旂旃旄旅旆旉旋旌旍旎族旐旒旓旖旗旙旚旛旝旞旟无旡既日旦旧旨早旬旭旮旯旰旱旲旳旴旵时旷旸旺旻旼旽旾旿昀昂昃昄昆昇昈昉昊昌昍明昏昐昒易昔昕昙昚昛昜昝昞星映昡昢昣昤春昦昧昨昪昫昬昭是昰昱昲昳昴昵昶昺昼昽显晁時晃晅晇晈晉晊晋晌晏晐晒晓晔晕晖晗晙晚晛晜晝晞晟晡晢晤晥晦晧晨晩晪晫晬晭普景晰晱晳晴晶晷晸智晻晼晾暀暁暂暃暄暅暆暇暈暉暊暋暌暍暎暏暐暑暕暖暗暘暝暟暠暡暢暦暧暨暩暫暮暰暱暲暴暵暶暸暹暻暾暿曀曄曅曆曇曈曉曊曋曌曏曒曔曖曙曚曛曜曝曠曡曣曦曧曨曩曬曭曮曰曲曳更曶曷書曹曺曼曽曾替最朁會朄朅月有朊朋服朏朐朒朓朔朕朖朗朘望朝朞期朡朢朣朦朧木未末本札朮术朱朳朴朵朶朸朹机朻朼朽朾朿杀杁杂权杄杅杆杇杈杉杋杌李杏材村杓杕杖杗杙杜杝杞束杠条杢杣杤来杧杨杩杪杬杭杮杯杰東杲杳杴杵杶杷杻杼松板极构枅枆枇枉枋枌枍枎枏析枒枓枕枖林枘枚枛果枝枞枠枡枢枣枥枧枨枪枫枭枯枰枱枲枳枴枵架枷枸枹枺枻枼柀柁柂柃柄柅柈柉柊柍柎柏某柑柒染柔柖柘柙柚柜柝柞柟柠柢柣柤查柧柩柫柬柮柯柰柱柲柳柴柵柶柷柸柹柺査柽柾柿栀栁栂栃栄栅标栈栉栊栋栌栎栏栐树栒栓栔栖栗栘栚栜栝栞栟校栢栨栩株栫栭栯栰栱栲栳栴栵样核根栻格栽栾桀桁桂桃桄桅框案桉桊桋桌桎桏桐桑桒桓桔桕桖桙桜桝桟桠桡桢档桤桥桦桧桨桩桫桭桮桯桱桲桴桵桶桷桹桺桼桾桿梀梁梂梃梅梆梇梉梌梏梐梒梓梔梗梘梛梜條梟梠梡梢梣梦梧梨梩梫梬梭梮梯械梱梲梳梴梵梶梼梽梾梿检棁棂棃棄棆棉棊棋棌棍棐棒棓棕棖棗棘棚棛棜棟棠棡棣棤棧棨棩棪棫棬森棯棰棱棲棳棵棶棷棸棹棺棻棼棽椀椁椄椅椆椇椈椉椋椌植椎椏椐椑椒椓椔椗椙検椟椠椤椥椪椭椰椲椳椴椵椶椸椹椽椿楀楂楄楅楈楉楊楎楒楓楔楕楖楗楘楙楚楛楝楞楟楠楡楢楣楤楥楦楧楨楩楪楫楬業楮楯楰楱楳極楶楷楸楹楺楼楽楿概榃榄榅榆榇榈榉榊榍榎榑榔榕榖榗榘榙榛榜榞榠榢榣榤榦榧榨榩榪榫榬榭榮榯榰榱榲榴榶榷榹榻榼榽榾榿槀槁槃槄槇槉槊構槌槍槎槐槑槓槔槕槗様槙槚槛槜槟槠槢槤槥槧槨槩槬槭槮槰槱槲槳槷槸槺槻槼槽槾槿樀樁樂樅樆樊樋樑樓樔樕樗樘標樛樝樞樟樠模樣樧樨権横樫樯樱樲樴樵樸樹樺樻樽樾樿橀橁橄橇橈橉橋橐橑橒橓橘橙橚橛橜橝橞機橡橢橤橥橦橧橨橪橫橭橱橶橹橼橾橿檀檁檃檄檇檉檊檍檎檐檑檓檔檕檖檗檛檜檝檞檟檠檡檢檣檥檦檨檩檫檬檮檯檳檵檸檹檻檽櫂櫃櫅櫆櫈櫋櫌櫐櫑櫓櫙櫚櫛櫜櫝櫞櫟櫠櫡櫥櫧櫨櫪櫫櫬櫰櫱櫳櫸櫹櫺櫻櫼櫾櫿欂欃欄欅欈欉權欋欏欐欑欒欖欘欞欠次欢欣欤欥欧欨欬欭欯欱欲欳欴欵欶欷欸欹欺欻欽款欿歂歃歅歆歇歈歉歊歋歌歍歎歐歓歔歕歙歛歜歟歠歡止正此步武歧歩歪歭歯歲歳歴歶歷歸歹死歼歾歿殀殁殂殃殄殆殇殈殉殊残殍殏殑殒殓殔殖殗殘殙殚殛殞殟殠殡殢殣殤殥殦殧殪殫殭殮殯殰殲殳殴段殶殷殺殻殼殽殿毀毁毂毃毄毅毆毇毈毉毊毋毌母毎每毐毒毓比毕毖毗毘毙毚毛毞毡毣毤毦毨毪毫毬毯毰毲毳毴毵毸毹毻毼毽毾毿氀氁氂氄氅氆氇氈氊氌氍氏氐民氓气氕氖気氘氙氚氛氟氠氡氢氣氤氦氧氨氩氪氫氬氮氯氰氲氳水氶氷永氹氻氽氾氿汀汁求汃汆汇汈汉汊汋汍汎汏汐汒汔汕汗汙汚汛汜汝汞江池污汤汦汧汨汩汪汫汭汯汰汱汲汳汴汶汸汹決汽汾沁沂沃沄沅沆沇沈沉沋沌沍沏沐沒沓沔沕沖沘沙沚沛沜沟没沢沣沤沥沦沧沨沩沪沫沬沭沮沯沱河沴沶沷沸油沺治沼沽沾沿況泂泃泄泅泆泇泉泊泌泐泒泓泔法泖泗泙泚泛泜泝泞泠泡波泣泥泧注泩泪泫泬泭泮泯泰泱泲泳泵泷泸泺泻泼泽泾洀洁洄洇洈洊洋洌洍洎洑洒洓洗洘洙洚洛洞洟洢洣洤津洧洨洩洪洫洭洮洱洲洳洴洵洶洸洹洺活洼洽派洿流浃浄浅浆浇浈浉浊测浍济浏浐浑浒浓浔浕浘浙浚浛浜浞浟浠浡浢浣浤浥浦浧浩浪浬浭浮浯浰浲浴浵浶海浸浹浺浼浿涂涄涅涆涇消涉涊涌涍涎涐涑涒涓涔涕涖涗涘涙涛涝涞涟涠涡涢涣涤润涧涨涩涪涫涬涮涯液涳涴涵涷涸涼涽涾涿淀淄淅淆淇淈淊淋淌淍淎淏淑淒淓淔淖淘淙淚淛淜淝淞淟淠淡淢淤淥淦淨淩淪淫淬淭淮淯淰深淳淴淵淶混淸淹淺添淼淽渀渃清渇済渉渊渋渌渍渎渐渑渓渔渕渗渙渚減渜渝渟渠渡渢渣渤渥渦渨温渫測渭渮港渰渱渲渳渴游渹渺渻渼渽渾渿湀湁湃湄湅湆湇湉湊湋湍湎湑湓湔湖湘湛湜湝湞湟湠湡湢湣湥湧湨湩湫湮湯湱湲湳湴湻湼湾湿満溁溃溅溆溇溈溉溋溍溎溏源溓溔準溘溙溚溛溜溝溞溟溠溢溥溦溧溪溫溮溯溰溱溲溳溴溵溶溷溹溺溻溼溽溾滀滁滂滃滄滅滆滇滈滉滋滌滍滎滏滑滒滓滔滕滖滗滘滙滚滛滜滝滞滟滠满滢滤滥滦滧滨滩滪滫滬滭滮滯滱滲滴滵滷滸滹滻滽滾滿漁漂漃漅漆漇漈漉漊漋漎漏漑漒漓演漕漖漘漙漚漠漢漣漤漥漦漧漩漪漫漬漭漮漯漰漱漲漳漴漵漶漷漸漹漻漼漾漿潀潁潃潄潅潆潇潋潍潎潏潐潑潒潔潕潖潗潘潙潚潛潜潝潞潟潠潢潤潦潩潬潭潮潯潰潲潳潴潵潶潷潸潺潼潽潾潿澀澁澂澄澅澆澇澈澉澋澌澍澎澐澒澓澔澖澗澛澜澞澠澡澣澤澥澦澧澨澩澪澫澭澮澯澱澳澴澶澹澻澼澽激濁濂濃濄濆濇濈濉濊濋濌濎濑濒濔濕濘濙濛濜濞濟濠濡濣濤濦濧濩濫濬濭濮濯濰濱濲濴濶濺濻濼濾濿瀀瀁瀄瀅瀆瀇瀉瀋瀌瀍瀎瀏瀑瀔瀕瀖瀘瀙瀚瀛瀝瀞瀟瀠瀡瀢瀣瀦瀧瀨瀩瀫瀬瀯瀰瀱瀲瀴瀵瀶瀷瀸瀹瀺瀼瀾瀿灂灃灄灈灉灊灋灌灏灑灕灖灗灘灛灝灞灟灡灣灤灧灨灩灪火灭灯灰灴灵灶灸灼災灾灿炀炁炅炆炉炊炌炎炒炔炕炖炘炙炜炝炟炣炤炫炬炭炮炯炰炱炲炳炷炸点為炻炼炽炾烀烁烂烃烈烉烊烋烏烑烒烓烔烖烘烙烛烜烝烟烠烤烦烧烨烩烫烬热烯烰烱烴烶烷烹烺烻烽焃焄焆焉焊焌焍焐焓焔焕焖焗焘焙焚焜焞焠無焣焦焮焯焰焱焴然焻焼焿煁煃煅煆煇煉煊煋煌煎煐煑煒煓煔煕煖煗煙煚煜煝煞煟煠煢煣煤煥煦照煨煩煬煮煲煳煴煵煶煸煺煽熀熂熄熅熇熈熉熊熏熐熒熔熖熗熘熙熚熛熜熟熠熤熥熨熬熯熰熱熲熳熵熸熹熺熻熼熾熿燀燁燂燃燄燅燈燉燊燋燎燏燐燒燔燕燖燗燘燙燚燜燝營燠燡燥燦燧燫燬燭燮燴燹燻燼燾燿爀爁爂爆爇爊爌爍爐爓爔爕爚爛爝爞爟爢爣爨爪爬爭爯爰爱爲爵父爷爸爹爺爻爼爽爾爿牀牁牂牄牆片版牉牋牌牍牎牏牒牓牕牖牘牙牚牛牝牟牠牡牢牣牤牥牦牧物牬牮牯牰牲牴牵牷牸特牺牻牼牽牾牿犀犁犂犄犅犆犇犈犉犊犋犌犍犎犏犐犑犒犓犕犖犗犘犚犛犝犞犟犠犢犣犤犥犦犧犨犩犪犬犮犯犰犴犵状犷犸犹犽犿狀狁狂狃狄狆狈狉狊狋狌狍狎狐狑狒狓狔狖狗狘狙狚狛狜狝狞狟狠狡狣狤狥狦狨狩狪狫独狭狮狯狰狱狲狳狴狶狷狸狹狺狻狼狽狾狿猀猁猂猃猄猇猈猊猋猎猏猑猒猓猕猖猗猘猙猛猜猝猞猟猡猢猣猥猦猧猨猩猪猫猬猭献猯猰猱猲猳猴猵猶猷猹猺猻猼猾猿獀獁獂獃獄獅獇獊獋獌獍獎獏獐獑獒獗獘獛獝獞獟獠獡獢獣獥獦獧獨獩獪獫獬獭獮獯獰獲獳獴獵獶獷獸獺獻獼獽獾獿玀玁玂玃玄玅玆率玈玉玊王玎玏玑玒玓玔玕玖玗玘玙玚玛玞玟玠玡玢玤玥玦玧玨玩玫玭玮环现玱玲玳玶玷玹玺玻玼玾玿珀珂珅珇珈珉珊珋珌珍珎珏珐珑珒珓珕珖珙珛珝珞珠珡珢珣珤珥珦珧珩珪珫班珮珰珲珵珶珷珸珹珺珽現琀琁球琄琅理琇琈琉琊琍琎琏琐琔琖琚琛琟琡琢琤琥琦琨琪琫琬琭琮琯琰琱琲琳琴琵琶琹琺琼琿瑀瑁瑂瑃瑄瑅瑆瑇瑈瑊瑋瑑瑒瑓瑔瑕瑖瑗瑙瑚瑛瑜瑝瑞瑟瑠瑢瑣瑤瑥瑧瑨瑩瑪瑬瑭瑯瑰瑱瑲瑳瑴瑵瑶瑷瑺瑽瑾瑿璀璁璃璄璅璆璇璈璉璊璋璌璎璐璒璕璗璘璙璚璜璞璟璠璡璢璣璥璦璧璨璩璪璫璬璮環璱璲璵璸璹璺璽璿瓀瓁瓅瓈瓊瓋瓌瓏瓑瓒瓔瓖瓘瓚瓛瓜瓝瓞瓟瓠瓡瓢瓣瓤瓥瓦瓨瓩瓬瓮瓯瓴瓵瓶瓷瓻瓽瓾瓿甀甂甃甄甈甋甌甍甏甐甑甒甓甔甕甖甗甘甙甚甜甝甞生甡產産甥甦用甩甪甫甬甭甮甯田由甲申电男甸甹町画甽甾甿畀畅畇畈畊畋界畎畏畐畑畔畖留畚畛畜畝畟畠畢畣畤略畦畧番畫畬畯異畱畲畳畴畵當畷畸畹畺畽畿疀疁疃疆疇疊疋疌疍疎疏疐疑疒疔疕疖疗疘疙疚疝疟疠疡疢疣疤疥疧疫疬疭疮疯疰疱疲疳疴疵疸疹疻疼疽疾疿痀痁痂痃痄病症痈痉痊痋痌痍痎痏痐痑痒痓痔痕痗痘痙痚痛痝痞痟痠痡痢痣痤痦痧痨痩痪痫痯痰痱痲痳痴痷痸痹痺痻痼痾痿瘀瘁瘃瘅瘆瘈瘉瘊瘋瘌瘍瘏瘐瘑瘓瘕瘖瘗瘘瘙瘛瘜瘝瘞瘟瘠瘡瘢瘣瘤瘥瘦瘧瘩瘪瘫瘭瘮瘯瘰瘱瘲瘳瘴瘵瘸瘺瘻瘼瘽瘾瘿癀療癃癄癅癆癇癈癉癌癍癐癒癓癔癖癗癘癙癜癞癟癠癡癢癣癤癥癧癩癪癫癬癭癮癯癰癱癲癵癸癹発登發白百癿皁皂的皆皇皈皊皋皎皏皐皑皓皕皖皙皚皛皜皝皞皤皦皫皭皮皯皰皱皲皴皵皷皸皺皻皽皾皿盂盃盄盅盆盇盈盉益盋盌盍盎盏盐监盒盓盔盖盗盘盛盜盝盞盟盡監盤盥盦盧盨盩盪盫盬盭目盯盱盲盳直盷相盹盺盻盼盾眀省眃眄眅眇眈眉眊看県眍眐眑眒眓眕眙眚眛眜眝眞真眠眢眣眥眦眨眩眬眭眯眱眲眳眴眵眶眷眸眹眺眼眽眾着睁睃睄睅睆睇睊睋睌睍睎睏睐睑睒睔睕睖睚睛睜睞睟睠睡睢督睥睦睧睨睩睪睫睬睭睮睯睹睺睼睽睾睿瞀瞁瞂瞄瞅瞇瞉瞋瞌瞍瞎瞏瞑瞒瞓瞖瞘瞚瞛瞜瞝瞞瞟瞠瞡瞢瞥瞧瞩瞪瞫瞬瞭瞰瞲瞳瞴瞵瞶瞷瞻瞼瞽瞿矁矂矇矉矊矌矍矎矏矐矓矔矕矗矘矙矚矛矜矞矠矢矣知矧矩矫矬短矮矯矰矱矲石矴矶矸矹矺矻矼矽矾矿砀码砂砄砅砆砉砋砌砍砎砏砐砑砒研砕砖砗砘砚砜砝砟砠砢砣砥砦砧砨砩砫砬砭砮砯砰砲砳破砵砷砸砹砺砻砼砾础硁硂硃硅硇硈硉硊硌硍硎硏硐硒硓硔硕硖硗硙硚硜硝硠硤硥硨硩硪硫硬硭确硯硰硱硼硾硿碁碃碄碅碆碇碈碉碌碍碎碏碑碓碔碕碗碘碚碛碜碞碟碡碣碥碧碨碩碪碫碭碰碱碲碳碴碶碸碹確碻碼碽碾磁磃磅磈磉磊磋磌磍磎磏磐磑磔磕磘磙磚磛磜磝磞磟磡磣磥磧磨磩磪磬磯磱磲磳磴磵磷磹磺磻磼磽磾磿礁礂礄礅礉礌礎礐礑礒礓礔礙礛礜礝礞礠礣礥礦礧礨礪礫礬礭礮礱礴礵示礼礽社礿祀祁祂祃祅祆祇祈祉祊祋祌祎祏祐祑祒祓祔祕祖祗祙祚祛祜祝神祟祠祡祢祣祤祥祧票祩祪祫祭祯祲祳祴祷祸祹祺祼祾祿禀禁禂禃禄禅禇禊禋禍禎福禑禒禓禔禕禖禗禘禚禛禜禟禠禡禤禦禧禨禩禪禫禬禭禮禰禱禳禴禵禷禸禹禺离禼禽禾禿秀私秃秅秆秈秉秊秋种秎秏科秒秔秕秖秘租秠秣秤秦秧秩秪秫秬秭秮积称秳秶秸秺移秽秾稀稂稃稅稆稈稉稊程稌稍税稑稒稔稗稘稙稚稜稞稟稠稣稫稬稭種稯稰稱稲稳稷稹稺稻稼稽稾稿穀穂穄穅穆穇穈穉穊穋穌積穎穏穑穖穗穙穛穜穟穠穡穢穣穤穧穨穩穫穬穭穮穰穱穴穵究穷穸穹空穽穾穿窀突窃窄窅窆窈窉窊窋窌窍窎窏窐窑窒窓窔窕窖窗窘窙窜窝窞窟窠窢窣窥窦窨窩窪窫窬窭窮窯窰窱窲窳窴窵窶窸窺窻窾窿竀竁竃竄竅竇竈竊立竑竖竘站竚竜竝竞竟章竢竣童竦竩竪竫竭端竴競竷竹竺竻竽竿笃笄笅笆笈笊笋笏笐笑笓笔笕笘笙笛笝笞笠笢笤笥符笨笩笪笫第笭笮笯笰笱笲笳笴笵笸笹笺笻笼笾筀筄筅筆筇筈等筊筋筌筍筏筐筑筒答策筘筚筛筜筝筞筠筡筢筤筥筦筧筩筬筭筮筯筰筱筲筳筴筵筶筷筹筻筼签简箄箅箆箇箈箊箋箌箍箎箏箐箑箒箓箔箕箖算箘箙箛箜箝箠管箢箦箧箨箩箪箫箬箭箯箱箴箷箸箹箾節篁範篆篇築篊篋篌篎篑篓篔篕篙篚篛篝篞篠篡篣篤篥篦篧篨篩篪篭篮篯篱篲篳篴篷篸篹篻篼篽篾篿簀簁簂簃簅簇簉簋簌簍簎簏簑簒簕簖簗簙簜簝簞簟簠簡簢簣簥簦簧簨簩簪簫簬簭簮簰簳簷簸簹簺簻簼簽簾簿籀籁籃籈籉籊籌籍籐籔籗籙籛籜籝籟籠籣籤籥籦籧籩籪籬籮籯籲米籴籸籹籺类籼籽籾粂粃粄粇粉粊粋粍粑粒粔粕粗粘粛粜粝粞粟粢粤粥粦粧粩粪粮粱粲粳粵粹粺粻粼粽精粿糀糁糅糇糈糉糊糌糍糎糒糔糕糖糗糙糜糝糞糟糠糢糧糨糪糬糯糰糱糲糴糵糶糷糸糹糺系糾紀紂紃約紅紆紇紈紉紊紋納紎紐紑紒紓純紕紖紗紘紙級紛紜紝紞紟素紡索紥紧紩紫紬紮累細紱紲紳紵紶紸紹紺紼紽紾紿絀絁終絃組絅絆絇経絎絏結絓絕絖絘絛絜絝絞絡絢絣給絧絨絪絫絭絮絯絰統絲絳絵絶絷絹絺絻絼絽絿綀綁綃綄綅綆綈綉綌綍綎綏綑經綔綖継続綜綝綞綟綠綢綣綦綧綪綫綬維綮綯綰綱網綳綴綵綷綸綹綺綻綼綽綾綿緀緁緂緃緄緅緆緇緈緊緋緌緎総緐緑緒緔緖緗緘緙線緛緜緝緞締緡緣緤緥緦緧編緩緪緬緯緰緱緲緳練緵緶緷緹緺緻縁縂縃縄縅縈縉縊縋縌縎縏縐縑縒縓縕縗縚縛縜縝縞縟縠縡縢縣縤縦縧縩縪縫縭縮縯縰縱縲縳縴縵縶縷縸縹縻縼總績縿繀繁繂繃繄繅繆繇繈繉繊繋繍繐繑繒織繕繖繘繙繚繜繝繞繟繠繡繢繣繦繧繩繪繫繭繮繯繰繲繳繴繵繶繷繸繹繺繻繼繽繾纀纁纂纆纇纈纊纋續纍纏纐纑纒纓纔纕纖纗纘纚纛纜纠纡红纣纤纥约级纨纩纪纫纬纭纮纯纰纱纲纳纴纵纶纷纸纹纺纻纼纽纾线绀绁绂练组绅细织终绉绊绋绌绍绎经绐绑绒结绔绕绖绗绘给绚绛络绝绞统绠绡绢绣绤绥绦继绨绩绪绫续绮绯绰绱绲绳维绵绶绷绸绹绺绻综绽绾绿缀缁缂缃缄缅缆缇缈缉缊缌缎缐缑缒缓缔缕编缗缘缙缚缛缜缝缞缟缠缡缢缣缤缥缦缧缨缩缪缫缬缭缮缯缰缱缲缳缴缵缶缸缹缺缽缾缿罂罃罄罅罇罈罉罊罋罌罍罎罏罐网罔罕罗罘罚罛罜罝罞罟罠罡罢罣罥罦罧罨罩罪罫罬罭置罰罱署罳罴罵罶罷罹罺罻罼罽罾罿羁羂羃羅羆羇羈羉羊羋羌羍美羑羒羓羔羕羖羗羙羚羛羜羝羞羟羠羡羢羣群羥羦羧羨義羬羭羯羰羱羲羳羴羵羶羷羸羹羺羼羽羾羿翀翁翂翃翄翅翇翈翊翋翌翍翎翏翐翑習翔翕翘翙翚翛翜翟翠翡翢翣翥翦翨翩翪翫翬翭翮翯翰翱翲翳翴翷翹翺翻翼翽翾翿耀老考耄者耆耇耋而耍耎耏耐耑耒耔耕耖耗耘耙耜耞耟耠耡耢耤耥耦耧耨耩耪耬耮耰耱耳耴耵耶耷耸耹耻耽耾耿聂聃聆聇聈聊聋职聍聏聑聒联聖聘聚聝聞聟聡聨聩聪聯聰聱聲聳聴聵聶職聹聽聾聿肂肃肄肅肆肇肉肋肌肎肏肐肒肓肕肖肘肙肚肛肜肝肟肠股肢肣肤肥肧肩肪肫肭肮肯肱育肴肵肷肸肹肺肼肽肾肿胀胁胂胃胄胅胆胇胈胉胊背胍胎胏胐胑胔胕胖胗胘胙胚胛胜胝胞胠胡胣胤胥胧胨胩胪胫胬胭胯胰胱胲胳胴胵胶胷胸胹胺胻胼能胾脀脁脂脃脅脆脇脈脉脊脍脎脏脐脑脒脓脔脕脖脗脘脙脚脛脞脟脡脣脤脥脧脩脫脬脭脯脰脱脲脳脶脷脸脹脽脾脿腃腄腆腇腈腊腋腌腍腎腏腐腑腒腓腔腕腖腘腙腚腛腜腞腠腢腤腥腦腧腨腩腫腭腮腯腰腱腲腳腴腶腷腸腹腺腻腼腽腾腿膀膂膃膆膇膈膉膊膋膌膍膏膑膕膘膙膚膛膜膝膞膟膠膢膣膥膦膨膩膫膬膮膰膱膲膳膴膵膷膹膺膻膽膾膿臀臂臃臄臅臆臇臈臉臊臋臌臍臏臐臑臒臓臕臗臘臙臚臛臜臝臞臟臠臡臢臣臥臦臧臨臩自臬臭臮臯臲至致臷臸臺臻臼臾臿舀舁舂舄舅舆與興舉舊舋舌舍舎舐舑舒舔舕舖舗舘舛舜舝舞舟舠舡舢舣舥舨舩航舫般舭舯舰舱舲舳舴舵舶舷舸船舺舻舼舽舾艀艂艄艅艇艉艋艎艏艐艑艒艓艔艕艘艙艚艛艟艤艦艨艫艮良艰艱色艳艴艵艶艷艸艹艺艼艽艾艿芀节芃芄芅芈芊芋芍芎芏芐芑芒芓芔芗芘芙芚芛芜芝芞芟芠芡芣芤芥芦芧芨芩芪芫芬芭芮芯芰花芲芳芴芵芷芸芹芺芻芼芽芾芿苀苁苄苅苇苈苉苊苋苌苍苎苏苑苒苓苔苕苖苗苘苙苛苜苝苞苟苠苡苣苤若苦苧苨苪苫苬苯英苲苳苴苵苶苷苹苺苻苼苾茀茁茂范茄茅茆茇茈茉茋茌茍茎茏茑茓茔茕茖茗茘茙茚茛茜茝茠茢茤茥茦茧茨茩茪茫茬茭茮茯茱茲茳茴茵茶茷茸茹茺茻茼茽茿荀荁荂荃荄荅荆荇草荊荍荎荏荐荑荒荓荔荖荘荙荚荛荜荞荟荠荡荣荤荥荦荧荨荩荪荫荬荭荮药荳荴荵荷荸荺荻荼荽荾莁莃莅莆莉莊莋莌莍莎莏莐莒莓莔莕莖莘莙莛莜莝莞莠莢莣莤莥莧莨莩莪莫莬莰莱莲莳莴莵莶获莸莹莺莼莽莿菀菁菂菅菆菇菈菉菊菋菌菍菎菏菑菓菔菕菖菘菙菜菝菞菟菠菡菣菤菥菧菨菩菪菫華菰菱菲菳菴菵菶菸菹菺菼菽菾菿萁萃萄萆萇萉萊萋萌萍萎萏萐萑萒萘萚萜萝萠萡萣萤营萦萧萨萩萬萭萯萰萱萲萳萴萵萷萸萹萺萼落萿葀葂葃葅葆葉葊葋葌葍葎葑葒葖著葙葚葛葜葝葞葟葡董葤葥葦葧葩葫葬葭葯葰葱葳葴葵葶葷葸葹葺葽蒂蒄蒆蒇蒈蒉蒋蒌蒍蒎蒏蒐蒓蒔蒗蒘蒙蒚蒛蒜蒝蒞蒟蒠蒡蒢蒤蒧蒨蒩蒪蒫蒬蒭蒮蒯蒱蒲蒴蒶蒸蒹蒺蒻蒼蒽蒾蒿蓀蓁蓂蓄蓆蓇蓉蓊蓋蓌蓍蓎蓏蓐蓑蓓蓖蓘蓚蓝蓟蓠蓢蓣蓥蓦蓧蓨蓩蓪蓫蓬蓭蓮蓯蓰蓱蓲蓳蓴蓶蓷蓹蓺蓻蓼蓽蓾蓿蔀蔂蔃蔄蔆蔇蔈蔉蔊蔌蔍蔎蔏蔑蔒蔓蔔蔕蔖蔗蔘蔚蔜蔝蔞蔟蔠蔡蔣蔤蔥蔦蔧蔨蔩蔪蔫蔬蔭蔮蔰蔱蔴蔵蔷蔸蔹蔺蔻蔼蔽蔾蔿蕀蕁蕃蕄蕅蕆蕇蕈蕉蕊蕋蕍蕎蕑蕒蕓蕕蕖蕗蕘蕙蕚蕛蕝蕞蕟蕠蕡蕢蕣蕤蕦蕧蕨蕩蕪蕫蕭蕮蕰蕱蕲蕴蕵蕷蕸蕹蕺蕻蕼蕾蕿薀薁薂薃薄薅薆薇薈薉薊薋薌薍薎薏薐薑薔薕薖薗薘薙薚薛薜薝薞薟薠薡薢薣薤薦薧薨薩薪薫薬薭薮薯薰薱薲薳薴薵薶薷薸薹薺薽薾薿藀藁藂藃藄藅藆藇藈藉藋藍藎藏藐藑藒藓藔藕藗藘藙藚藜藝藟藠藡藣藤藥藦藨藩藪藫藬藭藰藱藲藴藶藷藸藹藺藻藼藾藿蘀蘁蘂蘄蘅蘆蘇蘉蘊蘋蘌蘐蘑蘓蘖蘗蘘蘙蘚蘛蘜蘞蘟蘠蘡蘢蘣蘤蘥蘦蘧蘩蘪蘬蘭蘮蘱蘳蘴蘵蘶蘸蘹蘺蘻蘼蘾蘿虀虃虆虇虈虉虋虌虎虏虐虑虒虓虔處虖虙虚虛虜虞號虡虢虣虤虧虨虩虪虫虬虭虮虯虰虱虴虵虷虸虹虺虻虼虽虾虿蚀蚁蚂蚄蚅蚆蚇蚊蚋蚌蚍蚑蚓蚕蚖蚗蚘蚙蚚蚜蚝蚞蚡蚢蚣蚤蚥蚧蚨蚩蚪蚬蚯蚰蚱蚲蚳蚴蚵蚶蚷蚸蚹蚺蚻蚼蚿蛀蛁蛂蛃蛄蛅蛆蛇蛈蛉蛊蛋蛌蛍蛎蛏蛐蛑蛓蛔蛕蛖蛗蛘蛙蛚蛛蛜蛝蛞蛟蛢蛣蛤蛦蛩蛪蛫蛬蛭蛮蛯蛰蛱蛲蛳蛴蛵蛶蛷蛸蛹蛺蛻蛾蜀蜁蜂蜃蜄蜆蜇蜈蜉蜊蜋蜌蜍蜎蜐蜑蜒蜓蜕蜖蜗蜘蜙蜚蜛蜜蜞蜠蜡蜢蜣蜤蜥蜦蜧蜨蜩蜪蜬蜭蜮蜯蜰蜱蜲蜳蜴蜵蜷蜸蜺蜻蜼蜾蜿蝀蝁蝂蝃蝆蝇蝈蝉蝋蝌蝍蝎蝐蝑蝒蝓蝔蝕蝖蝗蝘蝙蝚蝛蝜蝝蝞蝟蝠蝡蝣蝤蝥蝦蝨蝪蝫蝬蝭蝮蝯蝰蝱蝲蝳蝴蝶蝷蝸蝹蝺蝻蝼蝽蝾蝿螁螂螃螄螅螇螈螋融螎螏螐螑螒螓螔螖螗螘螚螛螜螝螞螟螠螡螢螣螤螥螨螪螫螬螭螮螯螰螱螳螴螵螶螷螸螹螺螻螼螽螾螿蟀蟁蟂蟃蟄蟅蟆蟇蟈蟉蟊蟋蟌蟎蟏蟑蟒蟓蟔蟗蟘蟙蟛蟜蟞蟟蟠蟡蟢蟣蟤蟥蟦蟧蟨蟪蟫蟬蟭蟮蟯蟲蟳蟴蟶蟷蟹蟺蟻蟼蟾蟿蠀蠁蠂蠃蠄蠅蠆蠈蠉蠊蠋蠌蠍蠏蠐蠑蠓蠔蠕蠖蠗蠘蠙蠛蠜蠝蠟蠠蠡蠢蠣蠤蠥蠦蠨蠩蠪蠫蠬蠭蠮蠯蠰蠱蠲蠳蠵蠶蠷蠸蠹蠻蠼蠽蠾蠿血衁衂衃衄衅衆衇衈衊衋行衍衎衒術衔衕衖街衙衚衛衜衝衞衠衡衢衣补衧表衩衪衫衬衭衮衯衰衱衲衵衶衷衹衺衽衾衿袀袁袂袃袄袅袆袈袉袋袌袍袑袒袓袕袖袗袘袙袚袛袜袞袟袠袡袢袤袧袨袪被袬袭袯袰袱袲袴袵袶袷袸袹袺袼袽袾袿裀裁裂裃装裆裈裉裊裋裍裎裏裒裔裕裖裗裘裙裚裛補裝裞裟裠裡裢裣裤裥裧裨裬裮裯裰裱裲裳裴裵裶裷裸裹裺裻裼製裾裿褀褁褂褄褅褆複褊褋褌褍褎褐褑褒褓褔褕褖褗褘褙褚褛褞褟褡褢褥褦褧褩褪褫褭褮褯褰褱褲褳褴褵褶褷褸褻褼褽褾襁襂襃襄襆襇襋襌襏襐襒襓襕襖襗襘襚襛襜襝襞襟襠襡襢襣襤襦襩襪襫襬襭襮襯襲襳襴襶襷襹襺襻襾西要覂覃覅覆覇覈見覌覎規覓覕視覗覘覚覛覜覝覡覢覣覤覦覧覩親覬覭覮覯覲観覶覷覺覽覿觀见观觃规觅视觇览觉觊觋觌觎觏觐觑角觓觔觕觖觙觚觜觝觞觟觠觡觢解觤觥触觩觫觬觭觯觰觱觲觳觴觶觷觸觺觻觼觾觿言訁訂訃訄訇計訊訌討訏訐訑訒訓訔訕訖託記訚訛訝訞訟訢訣訥訧訩訪訬設訰許訳訴訶訹診註証訾訿詀詁詄詅詆詈詍詎詐詒詔評詖詗詘詙詛詝詞詟詠詡詢詣詥試詧詩詪詫詬詭詮詰話該詳詵詶詷詹詻詼詿誂誃誄誅誆誇誉誊誋誌認誑誒誓誕誖誘誙誚語誠誡誣誤誥誦誨說誫説読誰課誴誶誸誹誻誼誾調諀諂諄諅諆談諈諉請諌諍諏諐諑諒諓諔諕論諗諚諛諜諝諞諟諠諡諢諤諦諧諪諫諭諮諰諱諲諳諴諵諶諷諸諺諼諾謀謁謂謄謅謇謈謊謋謌謍謎謏謐謑謒謔謕謖謗謘謙謚講謜謝謞謠謡謢謣謤謥謦謧謨謩謪謫謬謯謰謱謳謵謷謹謼謾譀譁譅譆譇譈證譊譋譌譎譏譐譑譓譔譕譖識譙譚譛譜譝譞譟譠譣譥警譨譪譫譬譭譯議譲譳譴護譸譹譺譻譽譾譿讀讂讃讄讆變讋讌讎讐讒讓讔讕讖讘讙讚讜讞讟计订讣认讥讦讧讨让讪讫训议讯记讱讲讳讴讵讶讷许讹论讻讼讽设访诀证诂诃评诅识诇诈诉诊诋诌词诎诏诐译诒诓诔试诖诗诘诙诚诛诜话诞诟诠诡询诣诤该详诧诨诩诫诬语诮误诰诱诲诳说诵请诸诹诺读诼诽课诿谀谁谂调谄谅谆谇谈谊谋谌谍谎谏谐谑谒谓谔谕谖谗谙谚谛谜谝谞谟谠谡谢谣谤谥谦谧谨谩谪谫谬谭谮谯谰谱谲谳谴谵谶谷谹谻谼谽谾谿豁豃豅豆豇豈豉豊豋豌豍豎豏豐豔豕豗豚豜豝豟象豢豤豥豦豨豪豫豬豭豮豯豰豱豲豳豵豶豷豸豹豺豻豽豿貀貁貂貄貅貆貉貊貌貍貏貐貑貒貓貔貕貗貘貙貛貜貝貞負財貢貣貤貧貨販貪貫責貭貮貯貰貲貳貴貶買貸貺費貼貽貾貿賀賁賂賃賄賅資賈賉賊賌賎賑賒賓賔賕賙賚賛賜賞賟賠賡賢賣賤賥賦賧賨質賫賬賭賮賴賵賸賹賺賻購賽賾贄贅贆贇贈贊贋贌贍贏贐贓贔贕贖贗贙贛贜贝贞负贡财责贤败账货质贩贪贫贬购贮贯贰贱贲贳贴贵贶贷贸费贺贻贼贽贾贿赀赁赂赃资赅赆赇赈赉赊赋赌赍赎赏赐赑赒赓赔赕赖赗赘赙赚赛赜赝赞赟赠赡赢赣赤赦赧赨赩赪赫赬赭赮走赳赴赵赶起赸趀趁趂趄超越趋趌趍趎趏趐趑趓趔趕趖趙趜趟趠趡趣趥趧趨趪趫趬趭趮趯趱趲足趴趵趷趸趹趺趼趾趿跂跃跄跅跆跇跈跋跌跍跎跏跐跑跓跕跖跗跘跙跚跛跜距跞跟跠跡跢跣跤跥跦跧跨跩跪跫跬跮路跰跱跲跳跴践跶跷跸跹跺跻跼跽跾跿踂踃踅踆踇踉踊踌踍踎踏踐踑踒踓踔踕踖踘踙踛踝踞踟踠踡踢踣踤踥踦踧踩踪踫踬踮踯踰踱踳踴踵踶踸踹踺踼踽踾蹀蹁蹂蹄蹅蹇蹈蹉蹊蹋蹌蹍蹎蹏蹐蹑蹒蹓蹔蹕蹖蹗蹙蹚蹛蹜蹝蹞蹟蹠蹡蹢蹣蹤蹥蹦蹧蹩蹪蹬蹭蹯蹰蹲蹳蹴蹵蹶蹸蹺蹻蹼蹽蹾蹿躁躂躄躅躆躇躈躉躊躋躍躏躐躑躒躓躔躕躖躗躙躚躜躝躞躟躠躡躣躤躥躦躨躩躪身躬躭躯躰躲躳躶躺躽軀車軋軌軍軎軏軑軒軓軔軘軛軝軞軟転軤軥軦軧軨軫軬軮軯軱軲軵軶軷軸軹軺軻軼軽軾軿輀輁輂較輄輅輆輇輈載輊輋輌輐輑輒輓輔輕輖輗輘輚輛輜輝輞輟輠輣輤輥輦輩輪輬輭輮輯輲輳輴輵輶輷輸輹輻輾輿轀轂轃轄轅轆轇轈轉轍轎轏轐轑轒轓轔轕轖轗轘轙轚轛轝轞轟轠轡轢轤车轧轨轩轪轫转轭轮软轰轱轲轳轴轵轶轷轸轹轺轻轼载轾轿辀辁辂较辄辅辆辇辈辉辊辋辌辍辎辏辐辑辒输辔辕辖辗辘辙辚辛辜辞辟辠辢辣辥辦辧辨辩辫辭辮辯辰辱農辴辵边辺辻込辽达辿迁迂迄迅迆过迈迉迋迍迎运近迒迓返迕迖还这进远违连迟迡迢迣迤迥迦迨迩迪迫迭迮述迳迴迵迷迸迹迺迻追迾迿退送适逃逄逅逆逈选逊逋逌逍逎透逐逑递逓途逕逖逗這通逛逜逝逞速造逡逢連逤逦逨逩逭逮逯週進逴逵逶逸逹逺逻逼逾逿遁遂遄遅遆遇遊運遍過遏遐遑遒道達違遗遘遙遛遜遝遞遠遡遢遣遥遨適遭遮遯遰遲遳遴遵遶遷選遹遺遻遼遽遾避邀邁邂邃還邅邆邇邈邉邊邋邍邏邐邑邓邔邕邗邘邙邛邝邞邟邠邡邢那邥邦邧邨邪邬邮邯邰邱邲邳邴邵邶邸邹邺邻邽邾邿郁郃郄郅郇郈郊郋郎郏郐郑郓郔郕郖郗郘郙郚郛郜郝郞郟郠郡郢郣郤郥郦郧部郪郫郭郯郰郱郲郳郴郵郷郸郹郺郻郼都郾郿鄀鄁鄂鄃鄄鄅鄆鄇鄈鄉鄋鄌鄍鄎鄏鄐鄑鄒鄔鄖鄗鄘鄙鄚鄛鄜鄝鄞鄟鄠鄡鄢鄣鄤鄦鄧鄨鄩鄪鄫鄬鄭鄮鄯鄰鄱鄲鄳鄴鄵鄶鄸鄹鄺鄻鄾酀酁酂酃酄酅酆酇酈酉酊酋酌配酎酏酐酒酓酔酖酗酘酚酝酞酟酡酢酣酤酥酦酧酨酩酪酬酮酯酰酱酲酳酴酵酶酷酸酹酺酼酽酾酿醁醂醃醅醆醇醉醊醋醌醍醐醑醒醓醕醖醗醙醚醛醜醞醟醢醣醤醥醧醨醪醫醬醭醮醯醰醱醲醳醴醵醷醸醹醺醻醼醽醾醿釀釁釂釃釅釆采釈釉释釋里重野量釐金釓釔釕釗釘釙釚釜針釢釣釤釦釧釩釪釬釭釱釳釴釵釷釸釹釺釽釿鈀鈁鈃鈄鈆鈇鈈鈉鈊鈌鈍鈎鈏鈐鈑鈒鈔鈕鈖鈚鈞鈢鈣鈥鈦鈧鈫鈮鈰鈳鈴鈶鈷鈸鈹鈺鈽鈾鈿鉀鉄鉅鉆鉇鉈鉉鉊鉋鉌鉍鉏鉑鉒鉓鉕鉗鉚鉛鉞鉟鉠鉢鉤鉥鉦鉧鉬鉭鉮鉯鉱鉲鉴鉶鉷鉸鉹鉺鉻鉼鉾鉿銀銂銃銅銈銋銍銎銑銓銔銕銖銗銘銚銛銜銠銣銤銥銦銨銩銪銫銬銭銮銱銲銳銶銷銹銻銼銾鋀鋁鋂鋃鋄鋅鋆鋇鋈鋊鋋鋌鋏鋐鋒鋕鋗鋘鋙鋝鋟鋡鋤鋥鋦鋨鋩鋪鋭鋮鋯鋰鋱鋲鋳鋶鋸鋹鋺鋼錀錁錄錆錈錍錎錏錐錒錕錘錙錚錛錞錟錠錡錢錣錦錧錨錫錬錭錮錯録錳錵錶錸錻錼錾鍀鍁鍆鍇鍈鍉鍊鍋鍌鍍鍏鍐鍑鍔鍖鍘鍚鍛鍜鍝鍟鍠鍤鍥鍩鍪鍬鍭鍮鍰鍱鍳鍵鍶鍷鍺鍼鍾鎂鎃鎅鎉鎊鎌鎎鎏鎒鎓鎔鎖鎗鎘鎚鎛鎝鎞鎡鎢鎣鎦鎧鎩鎪鎬鎭鎮鎯鎰鎳鎵鎷鎸鎹鎻鏂鏃鏇鏈鏊鏌鏍鏏鏐鏑鏓鏖鏗鏘鏙鏚鏜鏝鏞鏟鏡鏢鏤鏦鏨鏬鏰鏳鏴鏵鏷鏹鏺鏻鏽鏾鐀鐃鐇鐈鐋鐍鐎鐏鐐鐓鐔鐕鐖鐘鐙鐚鐝鐠鐡鐤鐦鐧鐨鐫鐬鐭鐮鐯鐲鐳鐵鐶鐸鐺鐻鐽鐾鐿鑀鑁鑂鑄鑅鑊鑋鑌鑐鑑鑒鑔鑕鑚鑛鑞鑠鑢鑣鑤鑨鑪鑫鑭鑮鑯鑰鑱鑲鑴鑵鑷鑹鑼鑽鑾鑿钀钁钂钃钆钇针钉钊钋钌钍钎钏钐钒钓钔钕钖钗钘钙钚钛钜钝钞钟钠钡钢钣钤钥钦钧钨钩钪钫钬钭钮钯钰钱钲钳钴钵钷钹钺钻钼钽钾钿铀铁铂铃铄铅铆铈铉铊铋铌铍铎铏铐铑铒铕铖铗铘铙铚铛铜铝铞铟铠铡铢铣铤铥铧铨铩铪铫铬铭铮铯铰铱铲铳铴铵银铷铸铹铺铻铼铽链铿销锁锂锃锄锅锆锇锈锉锊锋锌锍锎锏锐锑锒锓锔锕锖锗锘错锚锛锜锝锞锟锡锢锣锤锥锦锧锨锩锪锫锬锭键锯锰锱锲锳锴锵锶锷锸锹锺锻锼锽锾锿镀镁镂镃镄镅镆镇镈镉镊镋镌镍镎镏镐镑镒镓镔镕镖镗镘镚镛镜镝镞镠镡镢镣镤镥镦镧镨镩镪镫镬镭镮镯镰镱镲镳镴镵镶長镺镻镼镽长門閂閃閆閇閈閉開閌閍閎閏閑閒間閔閘閜閞閟閡関閣閤閥閦閧閨閩閫閬閭閰閱閲閵閶閷閹閺閻閼閽閾閿闀闃闅闆闇闈闉闊闋闌闍闐闑闒闓闔闕闖闘闚闛關闞闟闠闡闢闤闥门闩闪闫闭问闯闰闱闲闳间闵闶闷闸闹闺闻闼闽闾闿阀阁阂阃阄阅阆阇阈阉阊阋阌阍阎阏阐阑阒阔阕阖阗阘阙阚阜阝阞队阠阡阢阤阨阪阬阭阮阯阰阱防阳阴阵阶阸阹阺阻阼阽阿陀陁陂附际陆陇陈陉陊陋陌降陎陏限陑陓陔陕陗陘陛陜陝陞陟陡院陣除陥陧陨险陪陫陬陭陰陲陳陴陵陶陷陸険陻陼陽陾隃隄隅隆隇隈隉隊隋隍階随隐隑隒隓隔隕隗隘隙際障隞隠隣隤隧隨隩險隮隰隱隲隳隴隶隷隸隹隺隻隼隽难隿雀雁雂雄雅集雇雈雉雊雋雌雍雎雏雑雒雓雔雕雖雗雘雙雚雛雜雝雞雟雠離難雨雩雪雫雯雰雱雲雳零雷雹雺電雽雾雿需霁霂霄霅霆震霈霉霊霋霍霎霏霐霑霒霓霖霙霜霞霠霢霣霤霧霨霩霪霫霬霭霮霰露霵霶霸霹霽霾霿靁靂靃靄靆靈靉靑青靓靖静靚靛靜非靠靡面靥靦靨革靪靫靬靭靮靰靱靲靳靴靶靷靸靺靻靼靽靾靿鞀鞁鞂鞃鞄鞅鞆鞈鞊鞋鞌鞍鞎鞏鞑鞒鞔鞗鞘鞙鞚鞜鞞鞠鞡鞣鞤鞥鞦鞧鞨鞪鞫鞬鞭鞮鞯鞲鞳鞴鞵鞶鞷鞹鞻鞽鞾鞿韁韂韃韄韅韆韇韉韋韌韍韎韏韐韑韓韔韕韗韙韜韝韞韟韠韡韣韥韦韧韨韩韪韫韬韭韮韰韱音韵韶韹韺韻韽韾響頁頂頃頄項順頇須頊頌頍頎頏預頑頒頓頔頖頗領頚頜頝頞頠頡頤頦頨頩頫頬頭頯頰頲頴頵頷頸頹頻頼頽顁顄顅顆顇顈顉顊顋題額顎顏顐顑顒顓顔顕顗願顙顛顜顝類顟顠顢顣顤顥顦顧顩顪顫顯顰顱顲顳顴页顶顷顸项顺须顼顽顾顿颀颁颂颃预颅领颇颈颉颊颋颌颍颎颏颐频颓颔颖颗题颙颚颛颜额颞颟颠颡颢颤颥颦颧風颬颭颮颯颱颲颳颶颸颺颻颼颽颾颿飁飂飄飆飈飉飋飌风飏飐飑飒飓飔飕飗飘飙飛飜飞食飡飢飣飥飧飨飩飪飫飬飭飮飯飱飲飴飶飺飼飽飾飿餀餂餃餄餅餈餉養餌餍餎餏餐餑餒餓餔餕餖餗餘餚餛餜餝餞餟餠餡餤餥餧館餪餫餬餭餮餯餰餱餲餳餵餷餹餺餻餼餽餾餿饁饂饃饅饇饈饉饊饋饌饍饎饐饑饒饔饕饗饘饙饛饜饞饟饡饢饥饧饨饩饪饫饬饭饮饯饰饱饲饳饴饵饶饷饸饹饺饻饼饽饿馁馃馄馅馆馇馈馉馊馋馌馍馏馐馑馒馓馔馕首馗馘香馛馝馞馣馥馦馧馨馬馭馮馯馰馱馲馳馴馵馹馺馻馼馽駁駂駃駄駅駆駈駉駍駎駏駐駑駒駓駔駕駖駘駙駛駜駝駟駡駢駣駤駥駩駪駬駭駮駰駱駴駶駷駸駹駺駻駼駽駾駿騁騂騃騄騅騇騉騊騋騌騍騎騏騑騒験騕騖騙騚騛騜騝騞騠騢騣騤騥騧騨騩騪騫騬騭騮騰騱騲騴騵騶騷騸騹騺騽騾驀驁驂驃驄驅驆驈驉驊驌驍驎驏驒驓驔驕驖驗驚驛驞驟驠驢驤驥驦驨驩驪驫马驭驮驯驰驱驲驳驴驵驶驷驸驹驺驻驼驽驾驿骀骁骂骃骄骅骆骇骈骉骊骋验骍骎骏骐骑骒骓骕骖骗骘骙骚骛骜骝骞骟骠骡骢骣骤骥骦骧骨骫骭骯骰骱骴骶骷骸骹骺骼骽骾骿髀髁髂髃髄髅髆髇髈髊髋髌髍髎髏髐髑髒髓體髕髖高髙髟髡髢髣髥髦髧髪髫髬髭髮髯髲髳髴髶髷髹髺髻髼髽髾鬀鬁鬃鬄鬅鬆鬈鬉鬊鬋鬌鬍鬎鬏鬐鬒鬓鬕鬖鬗鬘鬙鬚鬞鬟鬠鬢鬣鬤鬥鬧鬨鬩鬪鬫鬬鬮鬯鬱鬲鬳鬵鬶鬷鬹鬺鬻鬼鬾鬿魁魂魃魄魅魆魇魈魉魊魋魌魍魎魏魑魔魕魖魘魚魛魞魟魠魡魣魦魧魨魩魮魯魱魴魵魶魷魻魼魾鮀鮁鮂鮃鮄鮅鮆鮇鮊鮋鮍鮎鮐鮑鮒鮓鮗鮚鮛鮜鮝鮞鮟鮠鮡鮣鮤鮥鮦鮨鮪鮫鮭鮮鮰鮵鮸鮹鮻鮿鯀鯁鯃鯆鯇鯈鯉鯊鯏鯒鯓鯔鯕鯖鯗鯙鯛鯝鯞鯠鯡鯢鯤鯦鯧鯨鯪鯫鯬鯭鯮鯰鯱鯶鯷鯸鯻鯽鯿鰂鰃鰅鰆鰈鰉鰋鰌鰍鰏鰐鰒鰓鰕鰗鰛鰜鰝鰟鰣鰤鰥鰧鰨鰩鰫鰬鰭鰮鰯鰰鰱鰲鰳鰴鰶鰷鰹鰺鰻鰼鰾鰿鱀鱁鱂鱄鱅鱆鱇鱈鱉鱊鱋鱍鱐鱒鱓鱔鱕鱖鱗鱘鱚鱝鱞鱟鱠鱢鱣鱥鱦鱧鱨鱬鱭鱮鱰鱲鱳鱴鱵鱷鱸鱹鱺鱻鱼鱽鱾鱿鲀鲁鲂鲃鲅鲆鲇鲈鲉鲊鲋鲌鲍鲎鲏鲐鲑鲒鲔鲕鲖鲗鲘鲙鲚鲛鲜鲝鲞鲟鲠鲡鲢鲣鲤鲥鲦鲧鲨鲩鲪鲫鲬鲭鲮鲯鲰鲱鲲鲳鲴鲵鲷鲸鲹鲺鲻鲼鲽鲾鲿鳀鳁鳂鳃鳄鳅鳇鳈鳉鳊鳌鳍鳎鳏鳐鳑鳒鳓鳔鳕鳖鳗鳘鳙鳚鳛鳜鳝鳞鳟鳠鳡鳢鳣鳤鳥鳦鳧鳩鳪鳬鳭鳯鳰鳱鳲鳳鳴鳵鳶鳷鳹鳺鳻鳼鳽鳾鳿鴀鴃鴅鴆鴇鴈鴉鴎鴐鴒鴓鴔鴕鴗鴘鴛鴝鴞鴟鴠鴡鴢鴣鴥鴦鴨鴩鴫鴮鴯鴰鴱鴳鴴鴶鴷鴸鴻鴽鴾鴿鵀鵁鵂鵃鵄鵅鵊鵋鵌鵎鵏鵐鵑鵒鵓鵔鵖鵗鵙鵛鵜鵝鵞鵟鵠鵡鵧鵩鵪鵫鵬鵮鵯鵰鵱鵲鵳鵴鵵鵷鵸鵹鵺鵻鵼鵽鵾鶀鶁鶂鶄鶅鶆鶇鶈鶉鶊鶋鶌鶏鶐鶒鶓鶔鶖鶗鶘鶙鶚鶛鶜鶝鶞鶟鶠鶡鶢鶣鶤鶥鶨鶩鶪鶬鶭鶯鶱鶲鶴鶵鶶鶷鶹鶺鶻鶼鶾鶿鷀鷁鷂鷃鷄鷅鷇鷈鷉鷊鷋鷎鷏鷐鷑鷒鷓鷕鷖鷗鷘鷙鷚鷛鷜鷞鷟鷡鷢鷣鷤鷥鷦鷩鷫鷮鷯鷰鷲鷳鷵鷶鷷鷸鷹鷺鷻鷽鷾鷿鸀鸁鸂鸃鸄鸅鸆鸇鸉鸊鸋鸌鸍鸏鸐鸑鸒鸓鸔鸕鸗鸘鸙鸚鸛鸜鸝鸞鸟鸠鸡鸢鸣鸤鸥鸦鸧鸨鸩鸪鸫鸬鸭鸮鸯鸰鸱鸲鸳鸵鸶鸷鸸鸹鸺鸻鸼鸽鸾鸿鹀鹁鹂鹃鹄鹅鹆鹇鹈鹉鹊鹋鹌鹍鹎鹏鹐鹑鹒鹔鹕鹖鹗鹘鹙鹚鹛鹜鹝鹞鹟鹠鹡鹢鹣鹤鹦鹧鹨鹩鹪鹫鹬鹭鹮鹯鹰鹱鹲鹳鹴鹵鹸鹹鹺鹻鹼鹽鹾鹿麀麁麂麃麅麆麇麈麉麊麋麌麎麐麑麒麓麔麖麗麙麚麛麜麝麞麟麠麡麤麥麦麧麩麪麮麯麰麴麵麶麷麸麹麺麻麼麽麾麿黀黁黂黃黄黇黈黉黌黍黎黏黐黑黒黓黔黕黖默黙黚黛黜黝點黟黠黡黢黤黥黧黨黩黪黫黭黮黯黰黲黳黴黵黶黷黹黺黻黼黽黾黿鼀鼁鼂鼃鼆鼇鼈鼉鼊鼋鼍鼎鼏鼐鼒鼓鼕鼖鼗鼘鼙鼛鼜鼠鼢鼣鼤鼥鼨鼩鼪鼫鼬鼭鼮鼯鼰鼱鼲鼳鼴鼵鼶鼷鼸鼹鼻鼽鼾齀齁齂齃齇齉齊齋齌齍齎齏齐齑齒齔齕齖齗齘齙齛齜齝齞齟齠齡齢齣齤齥齦齧齩齪齫齬齮齯齰齱齲齴齵齶齷齸齹齺齻齾齿龀龁龂龃龄龅龆龇龈龉龊龋龌龍龎龐龑龒龔龕龗龙龚龛龜龝龟龠龢龤鿍鿎鿏郎凉︽︾﹏﹥﹪!#%&()+,-./0123456789:;=?@ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz|~・ヲァィゥェォャュョッアイウエオカキクケコサシスセソタチツテトナニヌネノハヒフヘホマミムメモヤユヨラリルレロワン゙゚¥𠅤𠙶𠳐𠴰𡎚𡐓𡑍𡚁𣇉𣗋𣲗𣲘𣸣𤋮𤧛𤩽𤫉𤺥𥔲𥕢𥖨𥻗𦈡𦍑𦒍𦙶𦝼𦭜𦰡𧿹𨐈𨙸𨚕𨟠𨭉𨱇𨱏𨱑𨱔𨺙𩓐𩽾𩾃𩾌𪟝𪣻𪤗𪨰𪨶𪩘𪾢𫄧𫄨𫄷𫄸𫇭𫌀𫍣𫍯𫍲𫍽𫐄𫐐𫐓𫑡𫓧𫓯𫓶𫓹𫔍𫔎𫔶𫖮𫖯𫖳𫗧𫗴𫘜𫘝𫘦𫘧𫘨𫘪𫘬𫚕𫚖𫚭𫛭𫞩𫟅𫟦𫟹𫟼𫠆𫠊𫠜𫢸𫫇𫭟𫭢𫭼𫮃𫰛𫵷𫶇𫷷𫸩𬀩𬀪𬂩𬃊𬇕𬇙𬇹𬉼𬊈𬊤𬌗𬍛𬍡𬍤𬒈𬒔𬒗𬕂𬘓𬘘𬘡𬘩𬘫𬘬𬘭𬘯𬙂𬙊𬙋𬜬𬜯𬞟𬟁𬟽𬣙𬣞𬣡𬣳𬤇𬤊𬤝𬨂𬨎𬩽𬪩𬬩𬬭𬬮𬬱𬬸𬬹𬬻𬬿𬭁𬭊𬭎𬭚𬭛𬭤𬭩𬭬𬭯𬭳𬭶𬭸𬭼𬮱𬮿𬯀𬯎𬱖𬱟𬳵𬳶𬳽𬳿𬴂𬴃𬴊𬶋𬶍𬶏𬶐𬶟𬶠𬶨𬶭𬶮𬷕𬸘𬸚𬸣𬸦𬸪𬹼𬺈𬺓🀄🃏🅰🅱🅾🅿🆎🆑🆒🆓🆔🆕🆖🆗🆘🆙🆚🇨🇩🇪🇫🇬🇮🇯🇰🇷🇺🈁🌀🌁🌂🌃🌄🌅🌆🌇🌈🌉🌊🌋🌌🌍🌎🌏🌐🌑🌒🌓🌔🌕🌖🌗🌘🌙🌚🌛🌜🌝🌞🌟🌠🌰🌱🌲🌳🌴🌵🌷🌸🌹🌺🌻🌼🌽🌾🌿🍀🍁🍂🍃🍄🍅🍆🍇🍈🍉🍊🍋🍌🍍🍎🍏🍐🍑🍒🍓🍔🍕🍖🍗🍘🍙🍚🍛🍜🍝🍞🍟🍠🍡🍢🍣🍤🍥🍦🍧🍨🍩🍪🍫🍬🍭🍮🍯🍰🍱🍲🍳🍴🍵🍶🍷🍸🍹🍺🍻🍼🎀🎁🎂🎃🎄🎅🎆🎇🎈🎉🎊🎋🎌🎍🎎🎏🎐🎑🎒🎓🎠🎡🎢🎣🎤🎥🎦🎧🎨🎩🎪🎫🎬🎭🎮🎯🎰🎱🎲🎳🎴🎵🎶🎷🎸🎹🎺🎻🎼🎽🎾🎿🏀🏁🏂🏃🏄🏆🏇🏈🏉🏊🏠🏡🏢🏣🏤🏥🏦🏧🏨🏩🏪🏫🏬🏭🏮🏯🏰🐀🐁🐂🐃🐄🐅🐆🐇🐈🐉🐊🐋🐌🐍🐎🐏🐐🐑🐒🐓🐔🐕🐖🐗🐘🐙🐚🐛🐜🐝🐞🐟🐠🐡🐢🐣🐤🐥🐦🐧🐨🐩🐪🐫🐬🐭🐮🐯🐰🐱🐲🐳🐴🐵🐶🐷🐸🐹🐺🐻🐼🐽🐾👀👂👃👄👅👆👇👈👉👊👋👌👍👎👏👐👑👒👓👔👕👖👗👘👙👚👛👜👝👞👟👠👡👢👣👤👥👦👧👨👩👪👫👬👭👮👯👰👱👲👳👴👵👶👷👸👹👺👻👼👽👾👿💀💁💂💃💄💅💆💇💈💉💊💋💌💍💎💏💐💑💒💓💔💕💖💗💘💙💚💛💜💝💞💟💠💡💢💣💤💥💦💧💨💩💪💫💬💭💮💯💰💲💳💴💵💶💷💸💹💺💻💼💽💾💿📀📁📂📃📄📅📆📇📈📉📊📋📌📍📎📏📐📑📒📓📔📕📖📗📘📙📚📛📜📝📞📟📠📡📢📣📤📥📦📧📨📩📪📫📬📭📮📯📰📱📲📳📴📵📶📷📹📺📻📼🔀🔁🔂🔃🔄🔅🔆🔇🔉🔊🔋🔌🔍🔎🔏🔐🔑🔒🔓🔔🔕🔖🔗🔘🔙🔚🔛🔜🔝🔞🔟🔠🔡🔢🔣🔤🔥🔦🔧🔨🔩🔪🔫🔬🔭🔮🔯🔰🔱🔲🔳🔴🔵🔶🔷🔸🔹🔺🔻🔼🔽🕐🕑🕒🕓🕔🕕🕖🕗🕘🕙🕚🕛🕜🕝🕞🕟🕠🕡🕢🕣🕤🕥🕦🕧🗻🗼🗽🗾🗿😀😁😂😃😄😅😆😇😈😉😊😋😌😍😎😏😐😑😒😓😔😕😖😗😘😙😚😛😜😝😞😟😠😡😢😣😤😥😦😧😨😩😪😫😬😭😮😯😰😱😲😳😴😵😶😷😸😹😺😻😼😽😾😿🙀🙅🙆🙇🙈🙉🙊🙋🙌🙍🙎🙏🚀🚁🚂🚃🚄🚅🚆🚇🚈🚉🚊🚌🚍🚎🚏🚐🚑🚒🚓🚔🚕🚖🚗🚘🚙🚚🚛🚜🚝🚞🚟🚠🚡🚢🚣🚤🚥🚦🚧🚨🚩🚪🚫🚬🚭🚮🚯🚰🚱🚲🚳🚴🚵🚶🚷🚸🚹🚺🚻🚼🚽🚾🚿🛀🛁🛂🛃🛄🛅 ';
diff --git a/packages/react-native-executorch/src/extensions/cv/tasks/ocr/detectors.ts b/packages/react-native-executorch/src/extensions/cv/tasks/ocr/detectors.ts
new file mode 100644
index 0000000000..07cca46f3f
--- /dev/null
+++ b/packages/react-native-executorch/src/extensions/cv/tasks/ocr/detectors.ts
@@ -0,0 +1,175 @@
+// Built-in detector box-extraction strategies (DBNet, CRAFT): each turns one
+// architecture's native detect output into quads. The pipeline is
+// model-agnostic — it just calls OcrModelOptions.extractBoxes.
+
+import { rnexecutorchJsi } from '../../../../native/bridge';
+import type { Tensor } from '../../../../core/tensor';
+import { quadsFromFlat, type Quad } from '../../ops/quad';
+import { boxesFromFlat, groupBoxes, boxToQuad } from './geometry';
+
+/**
+ * A detector's box-extraction strategy. Plug a new detector architecture into the
+ * OCR pipeline by supplying an object of this type (a built-in below, or your
+ * own). Both methods MUST be worklets.
+ * @category Types
+ */
+export type TextBoxExtractor = {
+ /**
+ * The detector input dims (W and H) must be a multiple of this — the pipeline
+ * snaps the letterbox size up to it before allocating, so `outputShapes` never
+ * receives a size it must reject. Default 1 (no alignment). CRAFT needs 2 (its
+ * heatmap is half-resolution).
+ */
+ readonly inputAlignment?: number;
+ /**
+ * Whether `extract(.., charLevel=true)` yields per-glyph boxes. When false the
+ * stacked-text (vertical) pass skips its char-level re-detection split and reads
+ * those boxes horizontally instead. Default false.
+ */
+ readonly supportsCharLevel?: boolean;
+ /**
+ * The `float32` output tensor shapes the `detect` method produces for a given
+ * detector input size, so the caller can pre-allocate them. One shape per
+ * `detect` output, in order.
+ * @param inputSize The detector input size the image was letterboxed to.
+ * @returns One shape per `detect` output tensor.
+ */
+ readonly outputShapes: (inputSize: {
+ readonly width: number;
+ readonly height: number;
+ }) => number[][];
+ /**
+ * Turns the model's `detect` output tensors into oriented {@link Quad}s in
+ * detector-input pixel space.
+ * @param outputs The model's `detect` output tensors, in order.
+ * @param inputSize The detector input size the image was letterboxed to.
+ * @param charLevel Emit one box per glyph instead of grouped lines; strategies
+ * without a char-level mode ignore it.
+ * @returns Oriented quads (TL, TR, BR, BL) in detector-input pixel space.
+ */
+ readonly extract: (
+ outputs: readonly Tensor[],
+ inputSize: { readonly width: number; readonly height: number },
+ charLevel: boolean
+ ) => Quad[];
+};
+
+/**
+ * CRAFT decode thresholds — tuning knobs for {@link makeCraftExtractBoxes}. All
+ * optional; the defaults suit the built-in CRAFT models.
+ * @category Types
+ */
+export type CraftExtractOptions = {
+ /** Region-score threshold for a pixel to count as text. Default 0.4. */
+ readonly textThreshold?: number;
+ /** Affinity-score threshold for linking adjacent glyphs into a line. Default 0.4. */
+ readonly linkThreshold?: number;
+ /** Minimum peak region score for a component to survive. Default 0.7. */
+ readonly lowTextThreshold?: number;
+};
+
+/**
+ * Builds a CRAFT box-extraction strategy: native region+affinity heatmap decode
+ * (`outputs[0]` = `[1, Hd, Wd, 2]`), then line grouping + de-skew in TS —
+ * per-glyph boxes when `charLevel`. Detector input dims must be even (half-res
+ * heatmap). {@link craftExtractBoxes} is this with default thresholds.
+ * @param opts Threshold overrides; omit for the built-in defaults.
+ * @returns A {@link TextBoxExtractor} bound to those thresholds.
+ * @category Typescript API
+ */
+export function makeCraftExtractBoxes(opts: CraftExtractOptions = {}): TextBoxExtractor {
+ const textThreshold = opts.textThreshold ?? 0.4;
+ const linkThreshold = opts.linkThreshold ?? 0.4;
+ const lowTextThreshold = opts.lowTextThreshold ?? 0.7;
+ return {
+ // Half-resolution heatmap: input dims must be even. The pipeline snaps to
+ // this, so the guard below only ever fires on a hand-rolled caller.
+ inputAlignment: 2,
+ supportsCharLevel: true,
+ outputShapes: ({ width, height }) => {
+ 'worklet';
+ if (width % 2 !== 0 || height % 2 !== 0) {
+ throw new Error(
+ 'OCR: CRAFT detect input dimensions must be even (half-resolution heatmap).'
+ );
+ }
+ return [[1, height / 2, width / 2, 2]];
+ },
+ extract: (outputs, inputSize, charLevel) => {
+ 'worklet';
+ const flat = rnexecutorchJsi.cv.extractCraftTextBoxes(outputs[0]!, {
+ textThreshold,
+ linkThreshold,
+ lowTextThreshold,
+ targetHeight: inputSize.height,
+ charLevel,
+ }) as number[];
+ // Char-level glyphs are read individually, so they skip line grouping.
+ const boxes = boxesFromFlat(flat);
+ return (charLevel ? boxes : groupBoxes(boxes)).map(boxToQuad);
+ },
+ };
+}
+
+/**
+ * CRAFT box-extraction strategy with default thresholds.
+ * @category Typescript API
+ */
+export const craftExtractBoxes: TextBoxExtractor = makeCraftExtractBoxes();
+
+/**
+ * DBNet decode thresholds — tuning knobs for {@link makeDbnetExtractBoxes}. All
+ * optional; the defaults suit the built-in DBNet models.
+ * @category Types
+ */
+export type DbnetExtractOptions = {
+ /** Binarization threshold on the probability map. Default 0.3. */
+ readonly binThreshold?: number;
+ /** Minimum mean box score to keep a candidate. Default 0.6. */
+ readonly boxThreshold?: number;
+ /** How far to expand (unclip) each shrunk box. Default 1.5. */
+ readonly unclipRatio?: number;
+ /** Discard boxes with a side smaller than this (px). Default 3. */
+ readonly minBoxSide?: number;
+ /** Cap on contour candidates scored per map. Default 1000. */
+ readonly maxCandidates?: number;
+};
+
+/**
+ * Builds a DBNet box-extraction strategy: thresholds and unclips the full-res
+ * post-sigmoid probability map (`outputs[0]` = `[1, 1, H, W]`) into oriented
+ * quads. No char-level mode. {@link dbnetExtractBoxes} is this with defaults.
+ * @param opts Threshold overrides; omit for the built-in defaults.
+ * @returns A {@link TextBoxExtractor} bound to those thresholds.
+ * @category Typescript API
+ */
+export function makeDbnetExtractBoxes(opts: DbnetExtractOptions = {}): TextBoxExtractor {
+ const binThreshold = opts.binThreshold ?? 0.3;
+ const boxThreshold = opts.boxThreshold ?? 0.6;
+ const unclipRatio = opts.unclipRatio ?? 1.5;
+ const minBoxSide = opts.minBoxSide ?? 3;
+ const maxCandidates = opts.maxCandidates ?? 1000;
+ return {
+ outputShapes: ({ width, height }) => {
+ 'worklet';
+ return [[1, 1, height, width]];
+ },
+ extract: (outputs) => {
+ 'worklet';
+ const flat = rnexecutorchJsi.cv.extractDbnetTextBoxes(outputs[0]!, {
+ binThreshold,
+ boxThreshold,
+ unclipRatio,
+ minBoxSide,
+ maxCandidates,
+ }) as number[];
+ return quadsFromFlat(flat);
+ },
+ };
+}
+
+/**
+ * DBNet box-extraction strategy with default thresholds.
+ * @category Typescript API
+ */
+export const dbnetExtractBoxes: TextBoxExtractor = makeDbnetExtractBoxes();
diff --git a/packages/react-native-executorch/src/extensions/cv/tasks/ocr/documentModels.ts b/packages/react-native-executorch/src/extensions/cv/tasks/ocr/documentModels.ts
new file mode 100644
index 0000000000..ac1e659a0d
--- /dev/null
+++ b/packages/react-native-executorch/src/extensions/cv/tasks/ocr/documentModels.ts
@@ -0,0 +1,504 @@
+import type { WorkletRuntime } from 'react-native-worklets';
+
+import { tensor, type Tensor } from '../../../../core/tensor';
+import { loadModel, type Model } from '../../../../core/model';
+import { validateModelSchema, SymbolicTensor } from '../../../../core/modelSchema';
+import { wrapAsync } from '../../../../core/runtime';
+
+import { IMAGENET_NORM } from '../../../../constants';
+import type { ImageFormat } from '../../image';
+import { FORMAT_CHANNELS, warpByGrid } from '../../ops/image';
+import { boundsOfPoints } from '../../ops/quad';
+import { createImagePreprocessor } from '../preprocessing';
+import type { OcrDetection } from './engine';
+
+/** A detected page orientation: clockwise rotation and classifier confidence `[0,1]`. */
+export type Orientation = {
+ readonly rotationCW: 0 | 90 | 180 | 270;
+ readonly confidence: number;
+};
+
+/**
+ * Table-structure recognition parameters: `structureVocab` maps a token id to its
+ * HTML fragment, `eosTokenId` ends decoding, `maxSteps` caps it.
+ * @category Types
+ */
+export type TableConfig = {
+ readonly structureVocab: readonly string[];
+ readonly eosTokenId: number;
+ readonly maxSteps: number;
+};
+
+/**
+ * Config for the fused document-helpers model (orientation + dewarp + table
+ * structure in one file). Every capability is opt-in; `orientation`/`dewarp` are
+ * defaults for the per-run flags of the same name.
+ * @category Types
+ */
+export type DocumentModelsConfig = {
+ readonly modelPath: string;
+ readonly orientation?: boolean;
+ readonly dewarp?: boolean;
+ readonly orientationMinConfidence?: number;
+ /**
+ * Decline the dewarp when it keeps less than this fraction of the source
+ * image variance (a bad grid can collapse a page without clear borders to near-blank).
+ * Default 0.5; lower = more permissive.
+ */
+ readonly dewarpMinVarianceRatio?: number;
+ readonly table?: TableConfig;
+};
+
+type ImagePreprocessor = ReturnType;
+
+// Index of the max value in `arr`. Shared by orientation + table.
+function argmax(arr: ArrayLike): number {
+ 'worklet';
+ let index = 0;
+ let best = arr[0]!;
+ for (let i = 1; i < arr.length; i++) {
+ if (arr[i]! > best) {
+ best = arr[i]!;
+ index = i;
+ }
+ }
+ return index;
+}
+
+// ===== Orientation ===========================================================
+
+function detectPageOrientation(
+ model: Model,
+ preprocessor: ImagePreprocessor,
+ tOri: Tensor,
+ oriBuf: Float32Array,
+ page: Tensor,
+ format: ImageFormat
+): Orientation {
+ 'worklet';
+ const tInput = preprocessor.processTensor(page, format);
+ model.execute('orientation', [tInput], [tOri]);
+ tOri.getData(oriBuf);
+ const cls = argmax(oriBuf);
+ const best = oriBuf[cls]!;
+ let sumExp = 0;
+ for (let i = 0; i < oriBuf.length; i++) {
+ sumExp += Math.exp(oriBuf[i]! - best);
+ }
+ return { rotationCW: (cls * 90) as 0 | 90 | 180 | 270, confidence: 1 / sumExp };
+}
+
+// ===== Dewarp ================================================================
+
+// Default for DocumentModelsConfig.dewarpMinVarianceRatio: a grid estimated on a
+// page without clear borders can collapse the output to near-blank, so decline the warp
+// when it keeps less than this fraction of the source variance.
+const DEFAULT_DEWARP_MIN_VARIANCE_RATIO = 0.5;
+const VARIANCE_SAMPLE_STRIDE = 31;
+
+// Variance of one channel, sampled sparsely — a cheap proxy for image content.
+function pixelVariance(data: Uint8Array, channels: number): number {
+ 'worklet';
+ let n = 0;
+ let sum = 0;
+ let sumSq = 0;
+ const step = channels * VARIANCE_SAMPLE_STRIDE;
+ for (let i = 0; i < data.length; i += step) {
+ sum += data[i]!;
+ sumSq += data[i]! * data[i]!;
+ n++;
+ }
+ if (n === 0) {
+ return 0;
+ }
+ return sumSq / n - (sum / n) * (sum / n);
+}
+
+// Estimates the sampling field and applies it (cv::remap). Returns the input
+// `page` unchanged when the warp is declined; caller owns whichever is returned.
+function dewarpPage(
+ model: Model,
+ preprocessor: ImagePreprocessor,
+ tGrid: Tensor,
+ page: Tensor,
+ format: ImageFormat,
+ minVarianceRatio: number
+): Tensor {
+ 'worklet';
+ const tInput = preprocessor.processTensor(page, format);
+ model.execute('dewarp', [tInput], [tGrid]);
+ const h = page.shape[0]!;
+ const w = page.shape[1]!;
+ const ch = FORMAT_CHANNELS[format];
+ const tDst = tensor('uint8', [h, w, ch]);
+ try {
+ warpByGrid(page, tGrid, tDst);
+ // Known cost: materializes both full frames to JS just to sample ~3% of one
+ // channel for the variance ratio — ~70MB of copies on a 12MP page. Fine while
+ // dewarp is opt-in and rare; move the variance into a native op if it gets hot.
+ const out = new Uint8Array(w * h * ch);
+ const src = new Uint8Array(w * h * ch);
+ tDst.getData(out);
+ page.getData(src);
+ if (pixelVariance(out, ch) < minVarianceRatio * pixelVariance(src, ch)) {
+ tDst.dispose();
+ return page;
+ }
+ return tDst;
+ } catch (e) {
+ tDst.dispose(); // caller can't free it on the throw path
+ throw e;
+ }
+}
+
+// ===== Table structure =======================================================
+
+/** The table decoder's pre-allocated tensors and staging buffers. */
+type TableDecodeState = {
+ readonly tFeatures: Tensor;
+ readonly tHidden: Tensor;
+ readonly tOnehot: Tensor;
+ readonly tProbs: Tensor;
+ readonly tNewHidden: Tensor;
+ readonly zeroHidden: Float32Array;
+ readonly zeroVocab: Float32Array;
+ readonly onehotBuf: Float32Array;
+ readonly probsBuf: Float32Array;
+};
+
+// Decodes the table's `/` HTML skeleton token-by-token; cell text is
+// filled in later from the region's OCR lines (fillTableCells).
+function recognizeTableStructure(
+ model: Model,
+ preprocessor: ImagePreprocessor,
+ state: TableDecodeState,
+ tableConfig: TableConfig,
+ page: Tensor,
+ format: ImageFormat
+): string {
+ 'worklet';
+ const { structureVocab, eosTokenId, maxSteps } = tableConfig;
+ const { tFeatures, tHidden, tOnehot, tProbs, tNewHidden } = state;
+ const tInput = preprocessor.processTensor(page, format);
+ model.execute('table_encode', [tInput], [tFeatures]);
+ tHidden.setData(state.zeroHidden);
+ // First step feeds an all-zero one-hot, not a 'sos' (index-0) one-hot: the
+ // exported decoder embeds its own start token, so the first real input is the
+ // zero vector. (Validated on-device — a 'sos' one-hot here would offset every
+ // decoded token by one. Revisit if a re-export changes the decoder's start.)
+ tOnehot.setData(state.zeroVocab);
+ let html = '';
+ for (let step = 0; step < maxSteps; step++) {
+ model.execute('table_decode_step', [tFeatures, tHidden, tOnehot], [tProbs, tNewHidden]);
+ tProbs.getData(state.probsBuf);
+ const tok = argmax(state.probsBuf);
+ if (tok === eosTokenId) {
+ break;
+ }
+ // Drop the reserved start/end token range from the assembled skeleton.
+ if (tok > 0 && tok < eosTokenId && tok < structureVocab.length) {
+ html += structureVocab[tok]!;
+ }
+ tNewHidden.copyTo(tHidden);
+ state.onehotBuf.fill(0);
+ state.onehotBuf[tok] = 1;
+ tOnehot.setData(state.onehotBuf);
+ }
+ return html;
+}
+
+// Clusters 1-D values into `k` groups at the k-1 widest gaps, each reduced to its
+// mean — matches how table cells distribute (tight within a row/col, gaps between).
+function clusterByGaps(values: readonly number[], k: number): number[] {
+ 'worklet';
+ if (k < 1) {
+ return [];
+ }
+ const sorted = [...values].sort((a, b) => a - b);
+ if (sorted.length <= k) {
+ return sorted;
+ }
+ const gaps = sorted.slice(1).map((value, i) => ({ at: i + 1, size: value - sorted[i]! }));
+ gaps.sort((a, b) => b.size - a.size);
+ const cuts = gaps
+ .slice(0, k - 1)
+ .map((gap) => gap.at)
+ .sort((a, b) => a - b);
+ const centers: number[] = [];
+ let prev = 0;
+ for (const cut of [...cuts, sorted.length]) {
+ const group = sorted.slice(prev, cut);
+ centers.push(group.reduce((sum, value) => sum + value, 0) / group.length);
+ prev = cut;
+ }
+ return centers;
+}
+
+// Fills a table skeleton's cells with a region's OCR lines, assigning each line's
+// box center to its nearest row/column cluster (document-order fallback if no grid).
+// Known limit: a line whose center falls inside a rowspan's vertical extent lands
+// on a row where that column has no (the span occupies it), so its text is
+// dropped — occupancy is tracked, content routing into spanned cells is not.
+export function fillTableCells(html: string, lines: readonly OcrDetection[]): string {
+ 'worklet';
+ const rowCount = (html.match(/ /g) ?? []).length;
+ let colCount = 0;
+ const rowRegex = / ([\s\S]*?)<\/tr>/g;
+ let row: RegExpExecArray | null;
+ while ((row = rowRegex.exec(html)) !== null) {
+ let cols = 0;
+ for (const td of row[1]!.match(/]*>/g) ?? []) {
+ cols += Number((td.match(/colspan="(\d+)"/) ?? [])[1] ?? 1);
+ }
+ colCount = Math.max(colCount, cols);
+ }
+ if (rowCount === 0 || colCount === 0 || lines.length === 0) {
+ let cell = 0;
+ const texts = lines.map((l) => l.text);
+ // Matches empty ` ` cells only — assumes the decoder emits the fused
+ // empty-cell token, which the current SLANet vocab does. A vocab that emitted
+ // pre-filled cells would need this widened.
+ return html.replace(/]*)><\/td>/g, (_match, attrs) => {
+ const text = cell < texts.length ? texts[cell]! : '';
+ cell++;
+ return ` ${text} `;
+ });
+ }
+
+ const centersX: number[] = [];
+ const centersY: number[] = [];
+ for (const line of lines) {
+ const box = boundsOfPoints(line.quad, 'xyxy');
+ centersX.push((box.xmin + box.xmax) / 2);
+ centersY.push((box.ymin + box.ymax) / 2);
+ }
+ const rowCenters = clusterByGaps(centersY, rowCount);
+ const colCenters = clusterByGaps(centersX, colCount);
+ const grid: string[][] = Array.from({ length: rowCenters.length }, () =>
+ new Array(colCenters.length).fill('')
+ );
+ for (let i = 0; i < lines.length; i++) {
+ const r = rowCenters.reduce(
+ (best, center, j) =>
+ Math.abs(centersY[i]! - center) < Math.abs(centersY[i]! - rowCenters[best]!) ? j : best,
+ 0
+ );
+ const c = colCenters.reduce(
+ (best, center, j) =>
+ Math.abs(centersX[i]! - center) < Math.abs(centersX[i]! - colCenters[best]!) ? j : best,
+ 0
+ );
+ grid[r]![c] = `${grid[r]![c]!} ${lines[i]!.text}`.trim();
+ }
+
+ const occupied: boolean[][] = [];
+ let rowIdx = -1;
+ return html.replace(/|]*)><\/td>/g, (match, attrs) => {
+ if (attrs === undefined) {
+ rowIdx++;
+ return match;
+ }
+ let c = 0;
+ while (occupied[rowIdx]?.[c]) {
+ c++;
+ }
+ const colspan = Number((attrs.match(/colspan="(\d+)"/) ?? [])[1] ?? 1);
+ const rowspan = Number((attrs.match(/rowspan="(\d+)"/) ?? [])[1] ?? 1);
+ for (let dr = 0; dr < rowspan; dr++) {
+ const rr = rowIdx + dr;
+ occupied[rr] ??= [];
+ for (let dc = 0; dc < colspan; dc++) {
+ occupied[rr]![c + dc] = true;
+ }
+ }
+ return ` ${grid[rowIdx]?.[c] ?? ''} `;
+ });
+}
+
+// ===== Contract + factory ====================================================
+
+// The tensor shapes the constructor pre-allocates from, grouped per model.
+type DocumentModelsShapes = {
+ readonly orientation: { readonly input: number[]; readonly output: number[] };
+ readonly dewarp: { readonly input: number[]; readonly grid: number[] };
+ readonly table?: {
+ readonly input: number[];
+ readonly features: number[];
+ readonly hidden: number[];
+ readonly probs: number[];
+ };
+};
+
+// Validates orientation + dewarp always, and the table methods only when a table
+// vocab size is given. Returns the shapes to pre-allocate from. Throws on mismatch.
+function resolveDocumentModelsShapes(model: Model, tableVocabSize?: number): DocumentModelsShapes {
+ const oriMeta = validateModelSchema(
+ model,
+ 'orientation',
+ [SymbolicTensor('float32', [1, 3, 'H', 'W'])],
+ [SymbolicTensor('float32', [1, 'K'])]
+ );
+ const dewMeta = validateModelSchema(
+ model,
+ 'dewarp',
+ [SymbolicTensor('float32', [1, 3, 'H', 'W'])],
+ [SymbolicTensor('float32', [1, 2, 'gH', 'gW'])]
+ );
+ const oriClasses = oriMeta.outputTensorMeta[0]!.shape[1]!;
+ if (oriClasses !== 4) {
+ throw new Error(
+ `DocumentModels: orientation head must output 4 classes ([0°, 90°, 180°, 270°]), ` +
+ `but the model declares ${oriClasses}.`
+ );
+ }
+ const base = {
+ orientation: {
+ input: oriMeta.inputTensorMeta[0]!.shape,
+ output: oriMeta.outputTensorMeta[0]!.shape,
+ },
+ dewarp: { input: dewMeta.inputTensorMeta[0]!.shape, grid: dewMeta.outputTensorMeta[0]!.shape },
+ };
+ if (tableVocabSize === undefined) {
+ return base;
+ }
+
+ const encMeta = validateModelSchema(
+ model,
+ 'table_encode',
+ [SymbolicTensor('float32', [1, 3, 'H', 'W'])],
+ [SymbolicTensor('float32', [1, 'C', 'F'])]
+ );
+ const decMeta = validateModelSchema(
+ model,
+ 'table_decode_step',
+ [
+ SymbolicTensor('float32', [1, 'C', 'F']),
+ SymbolicTensor('float32', [1, 'H']),
+ SymbolicTensor('float32', [1, 'V']),
+ ],
+ [SymbolicTensor('float32', [1, 'V']), SymbolicTensor('float32', [1, 'H'])]
+ );
+ const probs = decMeta.outputTensorMeta[0]!.shape;
+ // The decoder feeds each token back as a one-hot input, so its one-hot input dim
+ // must equal its output vocab (matchShape maps `V` per tensor, not across them).
+ const onehotVocab = decMeta.inputTensorMeta[2]!.shape[1]!;
+ if (onehotVocab !== probs[1]!) {
+ throw new Error(
+ `DocumentModels: table one-hot input vocab (${onehotVocab}) must match output vocab (${probs[1]}).`
+ );
+ }
+ if (probs[1]! !== tableVocabSize) {
+ throw new Error(
+ `DocumentModels: structure vocab (${tableVocabSize}) must match the model token dim (${probs[1]}).`
+ );
+ }
+ return {
+ ...base,
+ table: {
+ input: encMeta.inputTensorMeta[0]!.shape,
+ features: encMeta.outputTensorMeta[0]!.shape,
+ hidden: decMeta.outputTensorMeta[1]!.shape,
+ probs,
+ },
+ };
+}
+
+// Creates the document-helpers runner from one model file, loaded once. Each
+// capability is wired only when enabled in `config`. Internal to the OCR task.
+export async function createDocumentModels(
+ config: DocumentModelsConfig,
+ runtime?: WorkletRuntime
+): Promise<{
+ dispose: () => void;
+ detectOrientationWorklet: (page: Tensor, format: ImageFormat) => Orientation;
+ dewarpWorklet: (page: Tensor, format: ImageFormat) => Tensor;
+ recognizeTableWorklet: (page: Tensor, format: ImageFormat) => string;
+}> {
+ const { modelPath, table } = config;
+ const dewarpMinVarianceRatio = config.dewarpMinVarianceRatio ?? DEFAULT_DEWARP_MIN_VARIANCE_RATIO;
+ const model = await wrapAsync(loadModel, runtime)(modelPath);
+
+ // Track each artifact as it's built so the catch can dispose them all on a
+ // mid-sequence failure (no native leak on a bad config).
+ const created: { dispose: () => void }[] = [];
+ try {
+ const s = resolveDocumentModelsShapes(model, table?.structureVocab.length);
+
+ // Orientation + dewarp are always available (their methods are always present).
+ const orientationPreprocessor = createImagePreprocessor(
+ { resizeMode: 'stretch', interpolation: 'linear', ...IMAGENET_NORM },
+ s.orientation.input
+ );
+ created.push(orientationPreprocessor);
+ const dewarpPreprocessor = createImagePreprocessor(
+ { resizeMode: 'stretch', interpolation: 'linear', alpha: 1 / 255, beta: 0 },
+ s.dewarp.input
+ );
+ created.push(dewarpPreprocessor);
+ const tOri = tensor('float32', s.orientation.output);
+ created.push(tOri);
+ const tGrid = tensor('float32', s.dewarp.grid);
+ created.push(tGrid);
+ const oriBuf = new Float32Array(s.orientation.output[1]!);
+
+ let tablePreprocessor: ImagePreprocessor | null = null;
+ let decodeState: TableDecodeState | null = null;
+ if (table && s.table) {
+ tablePreprocessor = createImagePreprocessor(
+ { resizeMode: 'stretch', interpolation: 'linear', ...IMAGENET_NORM },
+ s.table.input
+ );
+ created.push(tablePreprocessor);
+ const tensors = [
+ tensor('float32', s.table.features),
+ tensor('float32', s.table.hidden),
+ tensor('float32', s.table.probs),
+ tensor('float32', s.table.probs),
+ tensor('float32', s.table.hidden),
+ ] as const;
+ tensors.forEach((t) => created.push(t));
+ const [tFeatures, tHidden, tOnehot, tProbs, tNewHidden] = tensors;
+ const hidLen = s.table.hidden[1]!;
+ const vocabLen = s.table.probs[1]!;
+ decodeState = {
+ tFeatures,
+ tHidden,
+ tOnehot,
+ tProbs,
+ tNewHidden,
+ zeroHidden: new Float32Array(hidLen),
+ zeroVocab: new Float32Array(vocabLen),
+ onehotBuf: new Float32Array(vocabLen),
+ probsBuf: new Float32Array(vocabLen),
+ };
+ }
+
+ const dispose = () => {
+ created.forEach((c) => c.dispose());
+ model.dispose();
+ };
+
+ // Thin executor bindings — logic lives in the top-level worklets above.
+ const detectOrientationWorklet = (page: Tensor, format: ImageFormat): Orientation => {
+ 'worklet';
+ return detectPageOrientation(model, orientationPreprocessor, tOri, oriBuf, page, format);
+ };
+ const dewarpWorklet = (page: Tensor, format: ImageFormat): Tensor => {
+ 'worklet';
+ return dewarpPage(model, dewarpPreprocessor, tGrid, page, format, dewarpMinVarianceRatio);
+ };
+ const recognizeTableWorklet = (page: Tensor, format: ImageFormat): string => {
+ 'worklet';
+ if (!tablePreprocessor || !decodeState || !table) {
+ throw new Error('DocumentModels: table recognition was not configured.');
+ }
+ return recognizeTableStructure(model, tablePreprocessor, decodeState, table, page, format);
+ };
+
+ return { dispose, detectOrientationWorklet, dewarpWorklet, recognizeTableWorklet };
+ } catch (e) {
+ created.forEach((c) => c.dispose());
+ model.dispose();
+ throw e;
+ }
+}
diff --git a/packages/react-native-executorch/src/extensions/cv/tasks/ocr/engine.ts b/packages/react-native-executorch/src/extensions/cv/tasks/ocr/engine.ts
new file mode 100644
index 0000000000..f2abe15eed
--- /dev/null
+++ b/packages/react-native-executorch/src/extensions/cv/tasks/ocr/engine.ts
@@ -0,0 +1,767 @@
+// OCR detect → recognize engine, internal to the OCR task. Worklet source order
+// matters: a referenced worklet must be defined above its callers.
+
+import { rnexecutorchJsi } from '../../../../native/bridge';
+import { tensor, type Tensor } from '../../../../core/tensor';
+import { validateModelSchema, SymbolicTensor } from '../../../../core/modelSchema';
+import type { Model, DimRange, InputShapeConstraint } from '../../../../core/model';
+
+import type { ImageBuffer, ImageFormat } from '../../image';
+import {
+ resize,
+ cvtColor,
+ rectifyQuad,
+ toChannelsFirst,
+ normalize,
+ FORMAT_CHANNELS,
+ FORMAT_CONVERSION,
+ type NormalizeOptions,
+} from '../../ops/image';
+import {
+ boundingQuadOf,
+ mapQuadToImage,
+ orderQuad,
+ quadSize,
+ splitTallQuad,
+ splitWideQuad,
+ type Quad,
+} from '../../ops/quad';
+import type { TextBoxExtractor } from './detectors';
+import { orderByReadingOrder, groupVerticalStacks } from './geometry';
+
+/**
+ * A single recognized text region. `quad` is the oriented (TL,TR,BR,BL) box in
+ * original image pixels (axis-aligned bounds via `boundsOfPoints(quad,'xyxy')`).
+ * @category Types
+ */
+export type OcrDetection = {
+ readonly text: string;
+ readonly confidence: number;
+ readonly quad: Quad;
+};
+
+// ─── Input size resolution ───────────────────────────────────────────────────
+
+// In the enumerated-shapes branch of resolveDetectorSize, an image at most this
+// fraction larger than a smaller legal shape still snaps DOWN to it, so a
+// marginal overflow doesn't force the next much-larger enumerated shape.
+const SNAP_DOWN_TOLERANCE = 0.1;
+
+// Snap a size UP to the smallest legal `min + k*step` that is also a multiple of
+// `align` (clamped to max); never shrinks content, only the range max can. `align`
+// (default 1) lets an extractor require even/aligned dims (e.g. CRAFT needs 2).
+function snapUpDim(size: number, range: DimRange, align = 1): number {
+ 'worklet';
+ const step = Math.max(1, range.step);
+ let steps = Math.ceil(Math.max(0, size - range.min) / step);
+ for (;;) {
+ const target = range.min + steps * step;
+ if (target > range.max) {
+ break;
+ }
+ if (target % align === 0) {
+ return target;
+ }
+ steps++;
+ }
+ // Overflow: largest legal lattice point `min + k*step <= max` that is aligned
+ // (plain `max` may not sit on the step lattice, nor be a multiple of align).
+ const maxSteps = Math.floor((range.max - range.min) / step);
+ for (let k = maxSteps; k >= 0; k--) {
+ const target = range.min + k * step;
+ if (target % align === 0) {
+ return target;
+ }
+ }
+ // No aligned size fits the range; return the largest legal one (the extractor's
+ // own guard will surface the misconfiguration).
+ return range.min + maxSteps * step;
+}
+
+// Largest legal detector input size (NCHW, dim 2 = H, dim 3 = W) that avoids
+// upscaling where the constraint allows (an enumerated set whose shapes all
+// exceed the image must upscale): dynamic ranges snap H/W independently; an
+// enumerated set picks the best-scale shape; a static shape is used as-is.
+function resolveDetectorSize(
+ constraint: InputShapeConstraint,
+ imgWidth: number,
+ imgHeight: number,
+ align: number
+): { detW: number; detH: number } {
+ 'worklet';
+ if (constraint.kind === 'range') {
+ const hRange = constraint.dims[2]!;
+ const wRange = constraint.dims[3]!;
+ const scale = Math.min(1, hRange.max / imgHeight, wRange.max / imgWidth);
+ return {
+ detW: snapUpDim(Math.round(imgWidth * scale), wRange, align),
+ detH: snapUpDim(Math.round(imgHeight * scale), hRange, align),
+ };
+ }
+ if (constraint.kind === 'static') {
+ return { detW: constraint.shape[3]!, detH: constraint.shape[2]! };
+ }
+ // Among enumerated shapes whose downscale is within tolerance prefer the
+ // smallest area (fastest); otherwise take the least downscale (most detail).
+ if (constraint.shapes.length === 0) {
+ throw new Error('OCR: detector declares an empty enumerated input-shape set.');
+ }
+ const minScale = 1 / (1 + SNAP_DOWN_TOLERANCE);
+ let best: readonly number[] | null = null;
+ let bestScale = -Infinity;
+ let bestArea = Infinity;
+ for (const shape of constraint.shapes) {
+ const scale = Math.min(shape[2]! / imgHeight, shape[3]! / imgWidth);
+ const area = shape[2]! * shape[3]!;
+ const withinTolerance = scale >= minScale;
+ const bestWithinTolerance = bestScale >= minScale;
+ const better = withinTolerance
+ ? !bestWithinTolerance || area < bestArea
+ : !bestWithinTolerance && scale > bestScale;
+ if (better) {
+ best = shape;
+ bestScale = scale;
+ bestArea = area;
+ }
+ }
+ return { detW: best![3]!, detH: best![2]! };
+}
+
+// Recognizer input width (NCHW dim 3): smallest legal width ≥ desired content, so
+// snapping never squishes (only the constraint max can). `align` (the CTC
+// width-to-timestep stride) constrains a dynamic range to stride-aligned widths so
+// the pre-sized probs tensor always matches; static/enum widths are stride-aligned
+// by the contract check, so `align` doesn't apply there.
+function resolveRecWidth(
+ constraint: InputShapeConstraint,
+ desiredWidth: number,
+ align: number
+): number {
+ 'worklet';
+ if (constraint.kind === 'range') {
+ return snapUpDim(desiredWidth, constraint.dims[3]!, align);
+ }
+ if (constraint.kind === 'static') {
+ return constraint.shape[3]!;
+ }
+ // Smallest enumerated width that fits the desired content, else the largest.
+ const widths = constraint.shapes.map((s) => s[3]!).sort((a, b) => a - b);
+ return widths.find((w) => w >= desiredWidth) ?? widths[widths.length - 1]!;
+}
+
+// ─── CTC decode ──────────────────────────────────────────────────────────────
+
+// Greedy-CTC decode of `[..,T,V]` probs: a native op returns per-timestep
+// [idx, value, ...]; drop blank (idx 0) + consecutive repeats, mean-conf the rest.
+function greedyCtcDecode(
+ probs: Tensor,
+ charset: readonly string[]
+): { text: string; conf: number } {
+ 'worklet';
+ const flat = rnexecutorchJsi.cv.ctcGreedyDecode(probs) as number[];
+ let text = '';
+ let last = -1;
+ let probabilitySum = 0;
+ let charCounter = 0;
+ for (let i = 0; i < flat.length; i += 2) {
+ const idx = flat[i]!;
+ if (idx >= 1) {
+ probabilitySum += flat[i + 1]!;
+ charCounter++;
+ if (idx !== last && idx < charset.length) {
+ text += charset[idx]!;
+ }
+ }
+ last = idx;
+ }
+ return { text, conf: charCounter === 0 ? 0 : probabilitySum / charCounter };
+}
+
+// ─── Execution ───────────────────────────────────────────────────────────────
+
+// A line at most this factor wider than the recognizer max is squished into it;
+// anything wider is split and read piecewise (a hard clamp is unreadable).
+const WIDE_SQUISH_TOLERANCE = 1.15;
+
+// Boxes smaller than this (px, either side) aren't worth recognizing.
+const MIN_BOX_SIDE = 3;
+
+// The recognizer input is contractually RGB (enforced by resolveOcrContract).
+const REC_CHANNELS = 3;
+
+// Detects text boxes in `src`, returning quads in `src` pixel space. `charLevel`
+// requests per-glyph boxes for the stacked-text pass.
+function detectQuads(
+ engine: OcrEngine,
+ src: Tensor,
+ width: number,
+ height: number,
+ format: ImageFormat,
+ charLevel = false
+): Quad[] {
+ 'worklet';
+ const numChannels = FORMAT_CHANNELS[format];
+ const toRgbCode = FORMAT_CONVERSION[format].rgb;
+ const align = Math.max(1, engine.extractBoxes.inputAlignment ?? 1);
+ const { detW, detH } = resolveDetectorSize(engine.det.constraint, width, height, align);
+ // Resolve output shapes before allocating — the extractor may throw (CRAFT on
+ // odd dims), and nothing must leak on that path.
+ const outputShapes = engine.extractBoxes.outputShapes({ width: detW, height: detH });
+ const tResize = tensor('uint8', [detH, detW, numChannels]);
+ const tColor = toRgbCode !== null ? tensor('uint8', [detH, detW, 3]) : null;
+ const tCF = tensor('uint8', [3, detH, detW]);
+ const tNorm = tensor('float32', [3, detH, detW]);
+ const tInput = tensor('float32', [1, 3, detH, detW]);
+ const tOutputs = outputShapes.map((shape) => tensor('float32', shape));
+ try {
+ src
+ .through(resize, tResize, { mode: 'letterbox', interpolation: 'area', padValue: 0 })
+ .throughIf(tColor !== null, cvtColor, tColor!, toRgbCode!)
+ .through(toChannelsFirst, tCF)
+ .through(normalize, tNorm, engine.det.norm)
+ .copyTo(tInput);
+
+ engine.model.execute('detect', [tInput], tOutputs);
+ const quads = engine.extractBoxes.extract(tOutputs, { width: detW, height: detH }, charLevel);
+ return quads.map((q) => mapQuadToImage(q, detW, detH, width, height));
+ } finally {
+ tResize.dispose();
+ tColor?.dispose();
+ tCF.dispose();
+ tNorm.dispose();
+ tInput.dispose();
+ for (const tOut of tOutputs) {
+ tOut.dispose();
+ }
+ }
+}
+
+// Recognizes a canvas already sized to a legal recognizer width — custom
+// `decode` if provided, else greedy CTC.
+function recognizeCanvas(
+ engine: OcrEngine,
+ tCanvas: Tensor,
+ snappedW: number
+): { text: string; conf: number } {
+ 'worklet';
+ const { recH, outStride, vocab, norm } = engine.rec;
+ const tCF = tensor('uint8', [REC_CHANNELS, recH, snappedW]);
+ const tNorm = tensor('float32', [REC_CHANNELS, recH, snappedW]);
+ const tInput = tensor('float32', [1, REC_CHANNELS, recH, snappedW]);
+ // CTC timesteps scale with the input width by the recognizer's downsample
+ // stride, so the dynamically-sized probs output can be pre-allocated.
+ const timesteps = Math.round(snappedW / outStride);
+ const tProbs = tensor('float32', [1, timesteps, vocab]);
+ try {
+ tCanvas.through(toChannelsFirst, tCF).through(normalize, tNorm, norm).copyTo(tInput);
+ engine.model.execute('recognize', [tInput], [tProbs]);
+ if (engine.decode) {
+ const r = engine.decode(tProbs, engine.charset);
+ return { text: r.text, conf: r.confidence };
+ }
+ return greedyCtcDecode(tProbs, engine.charset);
+ } finally {
+ tCF.dispose();
+ tNorm.dispose();
+ tInput.dispose();
+ tProbs.dispose();
+ }
+}
+
+// Recognizes one quad that fits the recognizer width (a mild squish is fine);
+// wider lines go through recognizeQuad instead.
+function recognizeNarrowQuad(
+ engine: OcrEngine,
+ src: Tensor,
+ quad: Quad
+): { text: string; conf: number } {
+ 'worklet';
+ const { recH, maxW, widthConstraint, outStride, padValue, padMode } = engine.rec;
+ const size = quadSize(quad);
+ // Aspect-preserving width of the content at recognizer height (>= 1 px).
+ const aspectWidth = Math.max(1, Math.round((recH * size.width) / Math.max(1, size.height)));
+ const contentW = Math.min(aspectWidth, maxW);
+ const snappedW = resolveRecWidth(widthConstraint, contentW, outStride);
+ const tCanvas = tensor('uint8', [recH, snappedW, REC_CHANNELS]);
+ try {
+ rectifyQuad(src, tCanvas, quad, {
+ contentWidth: contentW,
+ align: 'left',
+ padMode,
+ padValue,
+ });
+ return recognizeCanvas(engine, tCanvas, snappedW);
+ } finally {
+ tCanvas.dispose();
+ }
+}
+
+// Recognizes one ordered (TL,TR,BR,BL) quad. A line wider than the tolerance is
+// split into segments read piecewise; confidence is the length-weighted mean.
+function recognizeQuad(engine: OcrEngine, src: Tensor, quad: Quad): { text: string; conf: number } {
+ 'worklet';
+ const { recH, maxW } = engine.rec;
+ const size = quadSize(quad);
+
+ const desiredW = Math.max(1, Math.round((recH * size.width) / Math.max(1, size.height)));
+ if (desiredW <= maxW * WIDE_SQUISH_TOLERANCE) {
+ return recognizeNarrowQuad(engine, src, quad);
+ }
+ // Known limitation: segments abut with no overlap, so a glyph straddling a cut
+ // can be mangled/dropped at the seam. An overlap margin + de-dup would fix it;
+ // acceptable for now since a line this wide is the rare case.
+ const segments = splitWideQuad(quad, Math.ceil(desiredW / maxW));
+ let text = '';
+ let weightedConf = 0;
+ let weight = 0;
+ for (const segment of segments) {
+ const r = recognizeNarrowQuad(engine, src, segment);
+ if (r.text.length > 0) {
+ text += r.text;
+ weightedConf += r.conf * r.text.length;
+ weight += r.text.length;
+ }
+ }
+ return { text, conf: weight === 0 ? 0 : weightedConf / weight };
+}
+
+// Reads a vertical stack of glyph quads top-to-bottom: each glyph is recognized
+// on its OWN and the single-char results joined. Composing the glyphs into one
+// horizontal strip makes the recognizer hallucinate a word out of a non-word
+// column (its context was trained on horizontal words); reading them
+// individually drops that bias. A box the detector merged from several stacked
+// letters is first split into ~square cells. Confidence is the length-weighted
+// mean; null when nothing usable.
+function recognizeGlyphColumn(
+ engine: OcrEngine,
+ src: Tensor,
+ glyphs: readonly Quad[]
+): { text: string; conf: number } | null {
+ 'worklet';
+ let text = '';
+ let weightedConf = 0;
+ let weight = 0;
+ for (const glyph of glyphs) {
+ const glyphSize = quadSize(glyph);
+ if (glyphSize.width < 1 || glyphSize.height < 1) {
+ continue;
+ }
+ const parts = Math.max(1, Math.round(glyphSize.height / Math.max(1, glyphSize.width)));
+ for (const cell of splitTallQuad(glyph, parts)) {
+ const cellSize = quadSize(cell);
+ if (cellSize.width < 1 || cellSize.height < 1) {
+ continue;
+ }
+ const r = recognizeNarrowQuad(engine, src, cell);
+ if (r.text.length > 0) {
+ text += r.text;
+ weightedConf += r.conf * r.text.length;
+ weight += r.text.length;
+ }
+ }
+ }
+ return weight === 0 ? null : { text, conf: weightedConf / weight };
+}
+
+// Reads one tall box the detector merged from stacked glyphs: crop upright,
+// re-detect the glyphs (char-level), recognize top-to-bottom. Null (caller reads
+// it horizontally) when too small, out of re-detect budget, or no glyphs found.
+function readStackedBox(
+ engine: OcrEngine,
+ page: Tensor,
+ format: ImageFormat,
+ ordered: Quad,
+ size: { width: number; height: number },
+ budget: { remaining: number }
+): { text: string; conf: number } | null {
+ 'worklet';
+ const boxW = Math.round(size.width);
+ const boxH = Math.round(size.height);
+ if (boxW < MIN_BOX_SIDE || boxH < MIN_BOX_SIDE || budget.remaining <= 0) {
+ return null;
+ }
+ budget.remaining--;
+ const numChannels = FORMAT_CHANNELS[format];
+ const toRgbCode = FORMAT_CONVERSION[format].rgb;
+ const tBoxRaw = tensor('uint8', [boxH, boxW, numChannels]);
+ let tRecBox: Tensor | null = null;
+ try {
+ rectifyQuad(page, tBoxRaw, ordered, {
+ contentWidth: boxW,
+ align: 'left',
+ padMode: 'constant',
+ padValue: 0,
+ });
+ const charQuads = detectQuads(engine, tBoxRaw, boxW, boxH, format, /* charLevel */ true);
+ if (charQuads.length === 0) {
+ return null;
+ }
+ let boxSrc = tBoxRaw;
+ if (toRgbCode !== null) {
+ tRecBox = tensor('uint8', [boxH, boxW, REC_CHANNELS]);
+ boxSrc = cvtColor(tBoxRaw, tRecBox, toRgbCode);
+ }
+ const glyphs = charQuads.map((q) => orderQuad(q)).sort((a, b) => a[0]!.y - b[0]!.y);
+ return recognizeGlyphColumn(engine, boxSrc, glyphs);
+ } finally {
+ tBoxRaw.dispose();
+ tRecBox?.dispose();
+ }
+}
+
+// ─── OCR pass ────────────────────────────────────────────────────────────────
+
+const TALL_CROP_RATIO = 1.5;
+const MAX_VERTICAL_REDETECTIONS = 8;
+// Vertical reads are hallucination-prone, so they default to a modest gate; a
+// per-run `verticalMinConfidence` tunes it (0 keeps everything).
+const DEFAULT_VERTICAL_MIN_CONFIDENCE = 0.4;
+
+/**
+ * The resolved, model-level state one detect → recognize pass needs — the model
+ * itself, the validated detect/recognize contract, the CTC charset, and the run
+ * options. Built once by `createOcr`, reused across every page and per-region call.
+ */
+export type OcrEngine = {
+ readonly model: Model;
+ readonly extractBoxes: TextBoxExtractor;
+ readonly det: {
+ readonly constraint: InputShapeConstraint;
+ readonly norm: NormalizeOptions;
+ };
+ readonly rec: {
+ readonly recH: number;
+ readonly maxW: number;
+ readonly widthConstraint: InputShapeConstraint;
+ // Input-width pixels per CTC timestep (the recognizer's horizontal downsample).
+ readonly outStride: number;
+ // Recognizer output vocab size V (charset length incl. the blank).
+ readonly vocab: number;
+ readonly norm: NormalizeOptions;
+ readonly padValue: number;
+ // How the strip's unused width is filled: flat `padValue`, or the content's
+ // corner-mean background (avoids a seam some recognizers read as a glyph).
+ readonly padMode: 'constant' | 'cornerMean';
+ };
+ readonly charset: string[];
+ readonly minConfidence: number;
+ readonly decode?: (
+ probs: Tensor,
+ charset: readonly string[]
+ ) => { readonly text: string; readonly confidence: number };
+};
+
+// Per-run knobs for one OCR pass (the OCR-level subset of RunOcrOptions). A
+// RunOcrOptions value is structurally assignable, so callers pass it directly.
+type OcrPassOptions = {
+ readonly vertical?: boolean;
+ readonly tallCropRatio?: number;
+ readonly maxRedetections?: number;
+ readonly verticalMinConfidence?: number;
+};
+
+function pushDetection(
+ out: OcrDetection[],
+ threshold: number,
+ text: string,
+ conf: number,
+ quad: Quad
+): void {
+ 'worklet';
+ if (text.length > 0 && conf >= threshold) {
+ out.push({ text, confidence: conf, quad });
+ }
+}
+
+/**
+ * Runs one detect → recognize → reading-order pass over an on-device page tensor
+ * (uint8 HWC), returning the recognized regions in reading order. Does NOT own
+ * `page` — the caller allocates and disposes it — so the orientation/dewarp path
+ * can feed its corrected page tensor straight in without a round-trip through an
+ * {@link ImageBuffer}. The horizontal pass reads every quad; the opt-in vertical
+ * pass additionally reads upright glyph stacks.
+ * @param engine The resolved OCR engine (contract + model + run options).
+ * @param page The page tensor to read (uint8 HWC); caller-owned.
+ * @param width The page width in pixels.
+ * @param height The page height in pixels.
+ * @param format The pixel format of `page` (channels + color conversion).
+ * @param options Per-pass toggles (vertical text, tall-crop ratio, re-detect cap).
+ * @returns The recognized regions, in reading order.
+ */
+export function runOcrPassOnTensor(
+ engine: OcrEngine,
+ page: Tensor,
+ width: number,
+ height: number,
+ format: ImageFormat,
+ options?: OcrPassOptions
+): OcrDetection[] {
+ 'worklet';
+ const vertical = options?.vertical ?? false;
+ const tallCropRatio = options?.tallCropRatio ?? TALL_CROP_RATIO;
+ const maxRedetections = options?.maxRedetections ?? MAX_VERTICAL_REDETECTIONS;
+ const verticalMinConf = options?.verticalMinConfidence ?? DEFAULT_VERTICAL_MIN_CONFIDENCE;
+ const toRgbCode = FORMAT_CONVERSION[format].rgb;
+
+ let tRecImage: Tensor | null = null;
+ try {
+ const quads = detectQuads(engine, page, width, height, format);
+ if (quads.length === 0) {
+ return [];
+ }
+
+ let recSrc = page;
+ if (toRgbCode !== null) {
+ tRecImage = tensor('uint8', [height, width, REC_CHANNELS]);
+ recSrc = cvtColor(page, tRecImage, toRgbCode);
+ }
+ const budget = { remaining: maxRedetections };
+
+ const detections: OcrDetection[] = [];
+ const ordered: Quad[] = [];
+ for (const quad of quads) {
+ const orderedQuad = orderQuad(quad);
+ const size = quadSize(orderedQuad);
+ if (size.width >= MIN_BOX_SIDE && size.height >= MIN_BOX_SIDE) {
+ ordered.push(orderedQuad);
+ }
+ }
+
+ if (!vertical) {
+ for (const orderedQuad of ordered) {
+ const { text, conf } = recognizeQuad(engine, recSrc, orderedQuad);
+ pushDetection(detections, engine.minConfidence, text, conf, orderedQuad);
+ }
+ return orderByReadingOrder(detections);
+ }
+
+ // The stacked-box path re-detects glyphs char-level; only extractors that
+ // support that mode take it — others read the tall box horizontally.
+ const canReadStacks = engine.extractBoxes.supportsCharLevel ?? false;
+ const { stacks, singles } = groupVerticalStacks(ordered);
+ for (const stack of stacks) {
+ const column = recognizeGlyphColumn(engine, recSrc, stack);
+ if (column) {
+ pushDetection(detections, verticalMinConf, column.text, column.conf, boundingQuadOf(stack));
+ }
+ }
+ for (const orderedQuad of singles) {
+ const size = quadSize(orderedQuad);
+ if (canReadStacks && size.height >= size.width * tallCropRatio) {
+ const stacked = readStackedBox(engine, page, format, orderedQuad, size, budget);
+ if (stacked) {
+ pushDetection(detections, verticalMinConf, stacked.text, stacked.conf, orderedQuad);
+ continue;
+ }
+ }
+ const { text, conf } = recognizeQuad(engine, recSrc, orderedQuad);
+ pushDetection(detections, engine.minConfidence, text, conf, orderedQuad);
+ }
+ return orderByReadingOrder(detections);
+ } finally {
+ tRecImage?.dispose();
+ }
+}
+
+/**
+ * Runs one OCR pass over an {@link ImageBuffer}, uploading it to a page tensor
+ * and delegating to {@link runOcrPassOnTensor}.
+ * @param engine The resolved OCR engine (contract + model + run options).
+ * @param input The page (or region crop) to read.
+ * @param options Per-pass toggles (vertical text, tall-crop ratio, re-detect cap).
+ * @returns The recognized regions, in reading order.
+ */
+export function runOcrPass(
+ engine: OcrEngine,
+ input: ImageBuffer,
+ options?: OcrPassOptions
+): OcrDetection[] {
+ 'worklet';
+ const numChannels = FORMAT_CHANNELS[input.format];
+ const page = tensor('uint8', [input.height, input.width, numChannels]);
+ try {
+ page.setData(input.data);
+ return runOcrPassOnTensor(engine, page, input.width, input.height, input.format, options);
+ } finally {
+ page.dispose();
+ }
+}
+
+// ─── Contract ────────────────────────────────────────────────────────────────
+
+/**
+ * Validates the model's `detect` (RGB `[1,3,H,W]` in, model-defined outs) and
+ * `recognize` (`[1,C,H,W]` in, `[1,T,V]` probs out; only the width may vary)
+ * methods, resolves their legal input sizes from the model's own metadata, and
+ * builds the CTC charset. Runs at construction; throws on any contract mismatch.
+ * @param model The loaded fused detect/recognize model.
+ * @param charsetOption The recognizer charset (string = one codepoint per index;
+ * array = taken verbatim).
+ * @param extractBoxes The detector's box extractor — its alignment/char-level
+ * capabilities are validated against the model's detect input constraint.
+ * @returns The model-derived detect/recognize contract ({@link OcrEngine} minus
+ * the run options `createOcr` adds).
+ */
+export function resolveOcrContract(
+ model: Model,
+ charsetOption: string | readonly string[],
+ extractBoxes: TextBoxExtractor
+): {
+ det: Omit;
+ rec: Omit;
+ charset: string[];
+} {
+ // Validate the detect input is RGB [1,3,H,W]; wildcard the outputs (count from
+ // the model) so they stay unconstrained. The input size constraint itself comes
+ // from getInputShapeConstraints below.
+ const outCount = model.getMethodMeta('detect').outputTensorMeta.length;
+ validateModelSchema(
+ model,
+ 'detect',
+ [SymbolicTensor('float32', [1, 3, 'H', 'W'])],
+ Array.from({ length: outCount }, () => SymbolicTensor())
+ );
+ const detConstraint = model.getInputShapeConstraints('detect')[0]!;
+ // Reject a detector size the runtime can't resolve or the extractor can't decode
+ // — at load, not per run. A dynamic range is snapped to the extractor's alignment
+ // at run time, so it only needs numeric H/W; static/enum shapes must already be
+ // aligned, else `outputShapes` would throw on every run.
+ const detAlign = Math.max(1, extractBoxes.inputAlignment ?? 1);
+ if (detConstraint.kind === 'range') {
+ for (const dim of [2, 3]) {
+ const symbol = detConstraint.dims[dim]!.symbol;
+ if (symbol !== undefined) {
+ throw new Error(
+ `OCR: detector input dimension ${dim} is the named symbolic size '${symbol}', which ` +
+ `can't be resolved to a concrete input size.`
+ );
+ }
+ }
+ } else if (detAlign > 1) {
+ const shapes = detConstraint.kind === 'static' ? [detConstraint.shape] : detConstraint.shapes;
+ for (const shape of shapes) {
+ if (shape[2]! % detAlign !== 0 || shape[3]! % detAlign !== 0) {
+ throw new Error(
+ `OCR: detector input shape [${shape.join(', ')}] has H or W not a multiple of the ` +
+ `extractor's required alignment (${detAlign}); it would fail decode on every run.`
+ );
+ }
+ }
+ }
+
+ const recMeta = validateModelSchema(
+ model,
+ 'recognize',
+ [SymbolicTensor('float32', [1, 'C', 'H', 'W'])],
+ [SymbolicTensor('float32', [1, 'T', 'V'])]
+ );
+ const widthConstraint = model.getInputShapeConstraints('recognize')[0]!;
+ // A named symbolic recognizer dim has no numeric bound the pipeline can size to.
+ if (widthConstraint.kind === 'range') {
+ for (const [dim, range] of widthConstraint.dims.entries()) {
+ if (range.symbol !== undefined) {
+ throw new Error(
+ `OCR: recognizer input dimension ${dim} is the named symbolic size '${range.symbol}', ` +
+ `which the pipeline can't resolve. Export it with a numeric range or fixed size.`
+ );
+ }
+ }
+ }
+ // [min, max] extent of each NCHW input dim: a dynamic range gives them
+ // directly, a static shape has min === max, an enumerated set reduces across
+ // its shapes.
+ const extents: readonly (readonly [number, number])[] =
+ widthConstraint.kind === 'range'
+ ? widthConstraint.dims.map((d) => [d.min, d.max] as const)
+ : widthConstraint.kind === 'static'
+ ? widthConstraint.shape.map((s) => [s, s] as const)
+ : widthConstraint.shapes[0]!.map((_, dim) =>
+ widthConstraint.shapes.reduce(
+ ([min, max], shape) => [Math.min(min, shape[dim]!), Math.max(max, shape[dim]!)],
+ [Infinity, -Infinity]
+ )
+ );
+ for (const dim of [0, 1, 2]) {
+ const [min, max] = extents[dim]!;
+ if (min !== max) {
+ throw new Error(
+ `OCR: recognizer input dimension ${dim} must be fixed (only the width may vary), ` +
+ `but the model declares [${min}, ${max}].`
+ );
+ }
+ }
+ const recC = extents[1]![0];
+ const recH = extents[2]![0];
+ const maxW = extents[3]![1];
+ if (recC !== REC_CHANNELS) {
+ throw new Error(`OCR: recognizer must take RGB (3 channels), but the model expects ${recC}.`);
+ }
+
+ // CTC lookup: index 0 is the blank, then the model's characters (a string
+ // splits into codepoints; an array is taken verbatim, preserving ligatures).
+ const charset = [
+ '[blank]',
+ ...(typeof charsetOption === 'string' ? Array.from(charsetOption) : charsetOption),
+ ];
+ const outShape = recMeta.outputTensorMeta[0]!.shape;
+ const vocabSize = outShape[2]!;
+ if (charset.length !== vocabSize) {
+ throw new Error(
+ `OCR: charset size (${charset.length}, incl. blank) must match recognizer output vocab (${vocabSize}).`
+ );
+ }
+ // The recognizer reduces the input width onto the CTC time axis by a fixed
+ // integer factor; derive it from the model's static (width, timesteps) so the
+ // probs output can be pre-allocated for any legal width. This is a hard model
+ // contract: T must scale linearly with width. A non-integer ratio means the
+ // convolution stack pads/rounds and the linear assumption would mis-size it.
+ const recInputWidth = recMeta.inputTensorMeta[0]!.shape[3]!;
+ const recTimesteps = outShape[1]!;
+ if (recInputWidth % recTimesteps !== 0) {
+ throw new Error(
+ `OCR: recognizer input width (${recInputWidth}) must be an exact multiple of its CTC ` +
+ `timesteps (${recTimesteps}) — the pipeline assumes a fixed width-to-timestep stride.`
+ );
+ }
+ const outStride = recInputWidth / recTimesteps;
+
+ // `recognizeCanvas` pre-allocates the probs tensor as [1, snappedW/outStride, V],
+ // so the width the runtime picks must sit on the stride lattice (a multiple of
+ // outStride). For a range, `resolveRecWidth` snaps to a stride-aligned width, so
+ // we only need the range to admit ONE (residues mod outStride repeat with period
+ // ≤ outStride, so scanning that many lattice steps is exhaustive). For static/enum
+ // the runtime picks a declared width verbatim, so every candidate must be aligned.
+ let widthsLegal: boolean;
+ if (widthConstraint.kind === 'range') {
+ const range = widthConstraint.dims[3]!;
+ const step = Math.max(1, range.step);
+ widthsLegal = false;
+ for (let k = 0; k <= outStride; k++) {
+ const w = range.min + k * step;
+ if (w > range.max) {
+ break;
+ }
+ if (w % outStride === 0) {
+ widthsLegal = true;
+ break;
+ }
+ }
+ } else if (widthConstraint.kind === 'static') {
+ widthsLegal = widthConstraint.shape[3]! % outStride === 0;
+ } else {
+ widthsLegal = widthConstraint.shapes.every((s) => s[3]! % outStride === 0);
+ }
+ if (!widthsLegal) {
+ throw new Error(
+ `OCR: the recognizer width constraint is incompatible with the CTC width-to-timestep ` +
+ `stride (${outStride}) — no width the runtime would pick lands on the stride lattice, ` +
+ `so the probs tensor can't be pre-sized.`
+ );
+ }
+
+ return {
+ det: { constraint: detConstraint },
+ rec: { recH, maxW, widthConstraint, outStride, vocab: vocabSize },
+ charset,
+ };
+}
diff --git a/packages/react-native-executorch/src/extensions/cv/tasks/ocr/geometry.ts b/packages/react-native-executorch/src/extensions/cv/tasks/ocr/geometry.ts
new file mode 100644
index 0000000000..75daebe361
--- /dev/null
+++ b/packages/react-native-executorch/src/extensions/cv/tasks/ocr/geometry.ts
@@ -0,0 +1,444 @@
+// OCR box geometry: CRAFT component-box grouping + de-skew (ported from the
+// native path so box math lives in TS), reading order, and vertical-stack
+// clustering. Pure functions — no tensors, no model. Worklet source order
+// matters (callee above caller).
+
+import type { Point } from '../../ops/points';
+import { boundsOfPoints, type Quad } from '../../ops/quad';
+
+/**
+ * An axis-aligned box (`x0,y0` = min corner, `x1,y1` = max) plus the rotation
+ * angle (degrees) carried from the detector's rotated-rect.
+ */
+export type Box = {
+ x0: number;
+ y0: number;
+ x1: number;
+ y1: number;
+ angle: number;
+};
+
+// ─── CRAFT box grouping ──────────────────────────────────────────────────────
+
+// Empirically-tuned CRAFT grouping constants (detector-input pixels). Tune as a
+// group, not one-by-one.
+const CENTER_THRESHOLD = 0.5; // line-fit lateral tolerance (× candidate min-side)
+const DISTANCE_THRESHOLD = 2.0; // max inter-box gap to merge a line (× candidate height)
+const HEIGHT_THRESHOLD = 2.0; // max height mismatch to merge (× candidate height)
+const MIN_SHORT_SIDE = 15; // discard merged lines thinner than this (px)
+const MIN_LONG_SIDE = 30; // discard merged lines shorter than this (px)
+const VERTICAL_THRESHOLD = 20; // |Δx| of short-side midpoints below this ⇒ vertical
+
+const boxWidth = (b: Box): number => {
+ 'worklet';
+ return b.x1 - b.x0;
+};
+const boxHeight = (b: Box): number => {
+ 'worklet';
+ return b.y1 - b.y0;
+};
+const minSide = (b: Box): number => {
+ 'worklet';
+ return Math.min(boxWidth(b), boxHeight(b));
+};
+const maxSide = (b: Box): number => {
+ 'worklet';
+ return Math.max(boxWidth(b), boxHeight(b));
+};
+const center = (b: Box): Point => {
+ 'worklet';
+ return { x: (b.x0 + b.x1) / 2, y: (b.y0 + b.y1) / 2 };
+};
+const dist = (a: Point, b: Point): number => {
+ 'worklet';
+ return Math.hypot(b.x - a.x, b.y - a.y);
+};
+
+function corners(b: Box): [Point, Point, Point, Point] {
+ 'worklet';
+ return [
+ { x: b.x0, y: b.y0 },
+ { x: b.x1, y: b.y0 },
+ { x: b.x1, y: b.y1 },
+ { x: b.x0, y: b.y1 },
+ ];
+}
+
+function rotateAround(p: Point, ctr: Point, rad: number): Point {
+ 'worklet';
+ const tx = p.x - ctr.x;
+ const ty = p.y - ctr.y;
+ return {
+ x: tx * Math.cos(rad) - ty * Math.sin(rad) + ctr.x,
+ y: tx * Math.sin(rad) + ty * Math.cos(rad) + ctr.y,
+ };
+}
+
+// Fit a line to the two shortest sides' midpoints. The least-squares fit over two
+// points is just the line through them (cv::fitLine reduced to slope form).
+function fitLineToShortestSides(b: Box): {
+ slope: number;
+ intercept: number;
+ isVertical: boolean;
+} {
+ 'worklet';
+ const pts = corners(b);
+ const sides = pts.map((p, i) => {
+ const q = pts[(i + 1) % 4]!;
+ return { len: dist(p, q), mid: { x: (p.x + q.x) / 2, y: (p.y + q.y) / 2 } };
+ });
+ sides.sort((a, c) => a.len - c.len);
+ let m1 = sides[0]!.mid;
+ let m2 = sides[1]!.mid;
+ const isVertical = Math.abs(m2.x - m1.x) < VERTICAL_THRESHOLD;
+ if (isVertical) {
+ m1 = { x: m1.y, y: m1.x };
+ m2 = { x: m2.y, y: m2.x };
+ }
+ // Coincident midpoints (degenerate box) leave dx = 0; fall back to a flat line
+ // rather than propagating Infinity/NaN into every candidate distance.
+ const dx = m2.x - m1.x;
+ const slope = Math.abs(dx) < 1e-6 ? 0 : (m2.y - m1.y) / dx;
+ return { slope, intercept: m1.y - slope * m1.x, isVertical };
+}
+
+// Axis-aligned bounds of `b` after rotating its corners by `angleDeg`.
+function rotateBox(b: Box, angleDeg: number): Box {
+ 'worklet';
+ const ctr = center(b);
+ const rad = (angleDeg * Math.PI) / 180;
+ let minX = Infinity;
+ let minY = Infinity;
+ let maxX = -Infinity;
+ let maxY = -Infinity;
+ for (const p of corners(b)) {
+ const r = rotateAround(p, ctr, rad);
+ minX = Math.min(minX, r.x);
+ minY = Math.min(minY, r.y);
+ maxX = Math.max(maxX, r.x);
+ maxY = Math.max(maxY, r.y);
+ }
+ return { x0: minX, y0: minY, x1: maxX, y1: maxY, angle: b.angle };
+}
+
+function minDistanceBetween(a: Box, b: Box): number {
+ 'worklet';
+ let md = Infinity;
+ for (const c1 of corners(a)) {
+ for (const c2 of corners(b)) {
+ md = Math.min(md, dist(c1, c2));
+ }
+ }
+ return md;
+}
+
+// Every not-yet-consumed box whose center falls within the line's lateral
+// tolerance, sorted nearest-center-first (ties broken by original index — same
+// order the old nearest-first scan produced). The caller walks this list once
+// per line state instead of re-scanning per rejected box.
+function findLineCandidates(
+ boxes: Box[],
+ consumed: boolean[],
+ current: Box,
+ isVertical: boolean,
+ slope: number,
+ intercept: number
+): { index: number; height: number; dist: number }[] {
+ 'worklet';
+ const cc = center(current);
+ const out: { index: number; height: number; dist: number }[] = [];
+ for (let i = 0; i < boxes.length; i++) {
+ if (consumed[i]) {
+ continue;
+ }
+ const pc = center(boxes[i]!);
+ const h = minSide(boxes[i]!);
+ const lineDistance = isVertical
+ ? Math.abs(pc.x - (slope * pc.y + intercept))
+ : Math.abs(pc.y - (slope * pc.x + intercept));
+ if (lineDistance < h * CENTER_THRESHOLD) {
+ out.push({ index: i, height: h, dist: dist(cc, pc) });
+ }
+ }
+ out.sort((a, b) => a.dist - b.dist || a.index - b.index);
+ return out;
+}
+
+const mergeBoxes = (a: Box, b: Box): Box => {
+ 'worklet';
+ return {
+ x0: Math.min(a.x0, b.x0),
+ y0: Math.min(a.y0, b.y0),
+ x1: Math.max(a.x1, b.x1),
+ y1: Math.max(a.y1, b.y1),
+ angle: a.angle,
+ };
+};
+
+// Merges CRAFT component boxes into reading lines: greedily take the largest box,
+// fit a line through its short sides, absorb the nearest aligned box of similar
+// height, repeat; drop lines too small to read. Reading order is derived later.
+// `consumed[]` marks boxes already pulled into a line; candidates are gathered
+// once per line state and re-gathered only after a successful merge refits it.
+export function groupBoxes(input: Box[]): Box[] {
+ 'worklet';
+ const boxes = [...input].sort((a, b) => maxSide(b) - maxSide(a));
+ const consumed = new Array(boxes.length).fill(false);
+ const merged: Box[] = [];
+ for (let seed = 0; seed < boxes.length; seed++) {
+ if (consumed[seed]) {
+ continue;
+ }
+ consumed[seed] = true;
+ let current = boxes[seed]!;
+ const normalizedAngle = current.angle > 45 ? current.angle - 90 : current.angle;
+ let lineAngle = 0;
+ for (;;) {
+ const { slope, intercept, isVertical } = fitLineToShortestSides(current);
+ lineAngle = isVertical ? -90 : (Math.atan(slope) * 180) / Math.PI;
+ const candidates = findLineCandidates(boxes, consumed, current, isVertical, slope, intercept);
+ let mergedOne = false;
+ for (const candidateEntry of candidates) {
+ let candidate = boxes[candidateEntry.index]!;
+ // Only re-orient a candidate the detector emitted (near-)axis-aligned
+ // (angle ≈ 0 or 90, within the epsilon below) onto the seed's line; a box
+ // with any real skew already carries its own angle and is left as-is.
+ if (
+ (Math.abs(candidate.angle - 90) < 1e-3 && !isVertical) ||
+ (Math.abs(candidate.angle) < 1e-3 && isVertical)
+ ) {
+ candidate = rotateBox(candidate, normalizedAngle);
+ }
+ const gap = minDistanceBetween(candidate, current);
+ const currentHeight = minSide(current);
+ if (
+ gap < DISTANCE_THRESHOLD * candidateEntry.height &&
+ Math.abs(currentHeight - candidateEntry.height) < candidateEntry.height * HEIGHT_THRESHOLD
+ ) {
+ current = mergeBoxes(current, candidate);
+ consumed[candidateEntry.index] = true;
+ mergedOne = true;
+ break; // line grew — refit and re-gather
+ }
+ }
+ if (!mergedOne) {
+ break;
+ }
+ }
+ // Push a fresh box rather than writing `angle` through to `current` — for an
+ // unmerged seed `current` is still the caller's input element.
+ merged.push({ ...current, angle: lineAngle });
+ }
+ return merged.filter((b) => minSide(b) > MIN_SHORT_SIDE && maxSide(b) > MIN_LONG_SIDE);
+}
+
+// De-skews a box into an oriented quad (rotate corners about the center by the box
+// angle). Near-vertical boxes (|angle| > 45°) stay upright, not laid flat, so a
+// glyph stack stays a tall quad for the vertical reader.
+export function boxToQuad(b: Box): Quad {
+ 'worklet';
+ const boxCenter = center(b);
+ const rad = Math.abs(b.angle) > 45 ? 0 : (b.angle * Math.PI) / 180;
+ return corners(b).map((c) => rotateAround(c, boxCenter, rad));
+}
+
+// Parses a detector's flat box array — 5 per box: x0,y0,x1,y1,angle.
+export function boxesFromFlat(flat: readonly number[]): Box[] {
+ 'worklet';
+ if (flat.length % 5 !== 0) {
+ throw new Error(`boxesFromFlat: expected a multiple of 5 values, got ${flat.length}.`);
+ }
+ const boxes: Box[] = [];
+ for (let i = 0; i + 4 < flat.length; i += 5) {
+ boxes.push({
+ x0: flat[i]!,
+ y0: flat[i + 1]!,
+ x1: flat[i + 2]!,
+ y1: flat[i + 3]!,
+ angle: flat[i + 4]!,
+ });
+ }
+ return boxes;
+}
+
+// ─── Reading order & vertical grouping ───────────────────────────────────────
+
+// A gutter must be at least this fraction of the content width to split columns;
+// two boxes share a line when their vertical extents overlap by at least this
+// fraction of the shorter box's height.
+const COLUMN_GAP_FRACTION = 0.06;
+const LINE_OVERLAP_FRACTION = 0.3;
+
+// Reorders `{quad}` items the way a human reads the page: leftmost column
+// first (top to bottom), then the next column. Detectors emit boxes in
+// arbitrary order, so detections and assembled blocks are ordered through this.
+export function orderByReadingOrder(items: T[]): T[] {
+ 'worklet';
+ const count = items.length;
+ if (count <= 1) {
+ return items;
+ }
+
+ const boxes = items.map((it) => boundsOfPoints(it.quad, 'xyxy'));
+
+ // A vertical gap between boxes only counts as a column gutter when it is
+ // reasonably wide relative to the page content (else word spacing would
+ // split columns everywhere).
+ let minX = Infinity;
+ let maxX = -Infinity;
+ for (const box of boxes) {
+ if (box.xmin < minX) minX = box.xmin;
+ if (box.xmax > maxX) maxX = box.xmax;
+ }
+ const minGap = COLUMN_GAP_FRACTION * Math.max(1, maxX - minX);
+
+ // Find the gutters: walk every box's left/right edge in x order, keeping a
+ // running count of how many boxes overlap the current x. Count 0 means no
+ // box occupies this x — when such an empty stretch is wider than minGap,
+ // cut a column boundary at its midpoint.
+ const edges: { x: number; delta: number }[] = [];
+ for (const box of boxes) {
+ edges.push({ x: box.xmin, delta: 1 });
+ edges.push({ x: box.xmax, delta: -1 });
+ }
+ // At the same x, process a box's left edge before another's right edge —
+ // two boxes that exactly touch must not look like an empty stretch.
+ edges.sort((a, b) => a.x - b.x || b.delta - a.delta);
+ const cuts: number[] = [];
+ let coverage = 0;
+ // Seed to the first (leftmost) edge, not 0, so a page whose leftmost box starts
+ // well inside a left margin doesn't read that margin as an empty column gutter.
+ let gutterStart = edges.length > 0 ? edges[0]!.x : 0;
+ for (const edge of edges) {
+ const before = coverage;
+ coverage += edge.delta;
+ if (before > 0 && coverage === 0) {
+ gutterStart = edge.x;
+ } else if (before === 0 && coverage > 0 && edge.x - gutterStart >= minGap) {
+ cuts.push((gutterStart + edge.x) / 2);
+ }
+ }
+
+ // A box belongs to column k when exactly k cuts lie left of its center.
+ const columns: number[][] = Array.from({ length: cuts.length + 1 }, () => []);
+ for (let i = 0; i < count; i++) {
+ const centerX = (boxes[i]!.xmin + boxes[i]!.xmax) / 2;
+ let column = 0;
+ for (const cut of cuts) {
+ if (centerX > cut) column++;
+ }
+ columns[column]!.push(i);
+ }
+
+ // Inside each column: boxes whose vertical extents overlap enough sit on the
+ // same text line. Read lines top to bottom, and boxes within a line left to
+ // right.
+ const order: number[] = [];
+ for (const column of columns) {
+ column.sort((a, b) => boxes[a]!.ymin - boxes[b]!.ymin);
+ const lines: { items: number[]; ymin: number; ymax: number }[] = [];
+ for (const i of column) {
+ const box = boxes[i]!;
+ let placed = false;
+ for (const line of lines) {
+ // Overlap is measured against the SHORTER of the two heights, so a
+ // small box beside a tall one still joins its line.
+ const overlap = Math.min(line.ymax, box.ymax) - Math.max(line.ymin, box.ymin);
+ const minHeight = Math.min(line.ymax - line.ymin, box.ymax - box.ymin);
+ if (overlap >= LINE_OVERLAP_FRACTION * Math.max(1, minHeight)) {
+ line.items.push(i);
+ line.ymin = Math.min(line.ymin, box.ymin);
+ line.ymax = Math.max(line.ymax, box.ymax);
+ placed = true;
+ break;
+ }
+ }
+ if (!placed) {
+ lines.push({ items: [i], ymin: box.ymin, ymax: box.ymax });
+ }
+ }
+ lines.sort((a, b) => a.ymin - b.ymin);
+ for (const line of lines) {
+ line.items.sort(
+ (a, b) => boxes[a]!.xmin + boxes[a]!.xmax - (boxes[b]!.xmin + boxes[b]!.xmax)
+ );
+ order.push(...line.items);
+ }
+ }
+ return order.map((i) => items[i]!);
+}
+
+// A box wider than this multiple of its height is a horizontal line, never a
+// stacked glyph. A box joins a stack when its x-span overlaps the stack's by
+// STACK_X_OVERLAP of the narrower width and the y-gap is within STACK_Y_GAP of
+// its height.
+const STACK_GLYPH_ASPECT = 1.6;
+const STACK_X_OVERLAP = 0.25;
+const STACK_Y_GAP = 2.5;
+const STACK_UPWARD_OVERLAP = 0.5; // a box may reach back up this fraction of its height into the stack
+
+// Clusters glyph-like, x-aligned, vertically-stacked boxes into stacks (each
+// read top-to-bottom); wide lines and isolated boxes fall through as `singles`.
+// A "stack" is character-under-character vertical text — unrelated to the
+// page-layout columns of orderByReadingOrder above.
+export function groupVerticalStacks(quads: readonly Quad[]): {
+ stacks: Quad[][];
+ singles: Quad[];
+} {
+ 'worklet';
+ type Candidate = {
+ quad: Quad;
+ xmin: number;
+ xmax: number;
+ ymin: number;
+ ymax: number;
+ width: number;
+ height: number;
+ };
+ const candidates: Candidate[] = [];
+ const singles: Quad[] = [];
+ for (const q of quads) {
+ const { xmin, ymin, xmax, ymax } = boundsOfPoints(q, 'xyxy');
+ const width = xmax - xmin;
+ const height = ymax - ymin;
+ if (width > height * STACK_GLYPH_ASPECT) {
+ singles.push(q);
+ } else {
+ candidates.push({ quad: q, xmin, xmax, ymin, ymax, width, height });
+ }
+ }
+ // Grow each stack top-to-bottom from its current bottom box, checking
+ // alignment against the stack's accumulated x-range so a narrow glyph between
+ // wider ones doesn't break the run.
+ candidates.sort((a, b) => a.ymin - b.ymin);
+ type Stack = { boxes: Candidate[]; xmin: number; xmax: number; bottom: number };
+ const stacks: Stack[] = [];
+ for (const box of candidates) {
+ let placed = false;
+ for (const stack of stacks) {
+ const overlap = Math.min(box.xmax, stack.xmax) - Math.max(box.xmin, stack.xmin);
+ const aligned = overlap > STACK_X_OVERLAP * Math.min(box.width, stack.xmax - stack.xmin);
+ const gap = box.ymin - stack.bottom;
+ if (aligned && gap < STACK_Y_GAP * box.height && gap > -STACK_UPWARD_OVERLAP * box.height) {
+ stack.boxes.push(box);
+ stack.xmin = Math.min(stack.xmin, box.xmin);
+ stack.xmax = Math.max(stack.xmax, box.xmax);
+ stack.bottom = box.ymax;
+ placed = true;
+ break;
+ }
+ }
+ if (!placed) {
+ stacks.push({ boxes: [box], xmin: box.xmin, xmax: box.xmax, bottom: box.ymax });
+ }
+ }
+ const grouped: Quad[][] = [];
+ for (const stack of stacks) {
+ if (stack.boxes.length >= 2) {
+ grouped.push(stack.boxes.map((b) => b.quad));
+ } else {
+ singles.push(stack.boxes[0]!.quad);
+ }
+ }
+ return { stacks: grouped, singles };
+}
diff --git a/packages/react-native-executorch/src/extensions/cv/tasks/ocr/ocr.ts b/packages/react-native-executorch/src/extensions/cv/tasks/ocr/ocr.ts
new file mode 100644
index 0000000000..00a29b991a
--- /dev/null
+++ b/packages/react-native-executorch/src/extensions/cv/tasks/ocr/ocr.ts
@@ -0,0 +1,406 @@
+import type { WorkletRuntime } from 'react-native-worklets';
+
+import { tensor, type Tensor } from '../../../../core/tensor';
+import { loadModel } from '../../../../core/model';
+import { wrapAsync } from '../../../../core/runtime';
+import { IMAGENET_NORM } from '../../../../constants';
+
+import type { ImageBuffer } from '../../image';
+import { rotate, crop, FORMAT_CHANNELS, type NormalizeOptions } from '../../ops/image';
+import { boundsOfPoints, quadFromBounds } from '../../ops/quad';
+import type { BoundingBox } from '../../ops/boxes';
+import type { TextBoxExtractor } from './detectors';
+import {
+ runOcrPass,
+ runOcrPassOnTensor,
+ resolveOcrContract,
+ type OcrEngine,
+ type OcrDetection,
+} from './engine';
+import { orderByReadingOrder } from './geometry';
+import {
+ createObjectDetector,
+ type ObjectDetectorModel,
+ type ObjectDetection,
+} from '../objectDetection';
+import { createDocumentModels, fillTableCells, type DocumentModelsConfig } from './documentModels';
+
+export type { OcrDetection } from './engine';
+
+/**
+ * Model-specific OCR options. The pipeline validates the detect/recognize
+ * contract and resolves legal input sizes from the model metadata itself; these
+ * cover everything it can't infer.
+ * @category Types
+ */
+export type OcrModelOptions = {
+ /** Recognizer charset (string = one codepoint per index; array = taken verbatim). */
+ readonly charset: string | readonly string[];
+ /** Maps raw `detect` outputs to quads: {@link craftExtractBoxes} / {@link dbnetExtractBoxes}. */
+ readonly extractBoxes: TextBoxExtractor;
+ /**
+ * Drop detections below this recognition confidence. Default 0. Confidence is
+ * the mean per-timestep max probability, so this requires the recognizer to
+ * export a softmaxed head (values in `[0,1]`); on raw logits it is meaningless.
+ */
+ readonly minConfidence?: number;
+ /** Detector norm on uint8 RGB. Default ImageNet; must match the model's training norm. */
+ readonly detectorNorm?: NormalizeOptions;
+ /** Recognizer norm applied after the warp. Default `(x/255−0.5)/0.5` → `[−1,1]`. */
+ readonly recognizerNorm?: NormalizeOptions;
+ /** Recognizer canvas padding fill. Default 128 (neutral gray). */
+ readonly recognizerPadValue?: number;
+ /** Strip padding fill: `'constant'` (default) or `'cornerMean'` (background-matched). */
+ readonly recognizerPadMode?: 'constant' | 'cornerMean';
+ /**
+ * Custom decode replacing greedy CTC; receives softmaxed `[1,T,V]` probs. Must
+ * be a worklet. The probs tensor is pre-allocated from the recognizer's fixed
+ * width-to-timesteps ratio, so `T` always matches the run's input width.
+ */
+ readonly decode?: (
+ probs: Tensor,
+ charset: readonly string[]
+ ) => { readonly text: string; readonly confidence: number };
+};
+
+/**
+ * Model configuration for the OCR pipeline: one fused detect/recognize PTE plus
+ * its options, with optional `layout` (group text into regions) and
+ * `documentModels` (orientation/dewarp/table) stages. Omit both for whole-page OCR.
+ * @category Types
+ * @typeParam L The layout region class-label type.
+ */
+export type OcrModel = {
+ readonly modelPath: string;
+ readonly ocrOpts: OcrModelOptions;
+ readonly layout?: ObjectDetectorModel<'xyxy', L>;
+ readonly documentModels?: DocumentModelsConfig;
+ /** Layout labels carrying no text — OCR skips these regions. Default `['image', 'chart', 'seal']`. */
+ readonly visualLabels?: readonly string[];
+ /** The layout label treated as a table (structure recognition + cell fill). Default `'table'`. */
+ readonly tableLabel?: string;
+};
+
+/**
+ * Per-run OCR options — toggled without reloading. `orientation`/`dewarp` are
+ * no-ops without loaded `documentModels` and default to its config flags.
+ * @category Types
+ */
+export type RunOcrOptions = {
+ /**
+ * Also read upright glyph stacks (vertical text) — extra compute. Fully
+ * supported with CRAFT; partial with DBNet (glyph columns are still read, but
+ * the stacked-box char-level re-detection split is skipped).
+ */
+ readonly vertical?: boolean;
+ /** Height/width ratio above which a box is a vertical stack. Default 1.5. */
+ readonly tallCropRatio?: number;
+ /** Max stacked-box re-detection passes per page. Default 8. */
+ readonly maxRedetections?: number;
+ /** Drop vertical reads below this confidence. Default 0.4; set 0 to keep all. */
+ readonly verticalMinConfidence?: number;
+ /** Rotate the page upright first. No-op without loaded `documentModels`; defaults to its config flag. */
+ readonly orientation?: boolean;
+ /** Flatten a physically-warped page first. No-op without loaded `documentModels`; defaults to its config flag. */
+ readonly dewarp?: boolean;
+ /** Recognize table structure in table regions. Needs loaded `documentModels` (with a table config) + layout. Default true. */
+ readonly tables?: boolean;
+};
+
+/**
+ * One assembled document block: a layout region (or an ungrouped catch-all) with
+ * its OCR lines in reading order and their texts joined by newlines. All
+ * coordinates are in the {@link OcrResult} `image` frame.
+ * @category Types
+ */
+export type DocumentBlock = {
+ readonly regionType: L | 'ungrouped';
+ readonly bbox: BoundingBox<'xyxy'>;
+ /** The layout model's confidence for the region (1 for `'ungrouped'`). */
+ readonly layoutConfidence: number;
+ readonly text: string;
+ readonly lines: readonly OcrDetection[];
+ readonly isTable: boolean;
+ /** The recognized table structure HTML with OCR text filled into its cells. */
+ readonly tableHtml?: string;
+};
+
+/**
+ * The result of one OCR run. `detections` is always the flat list of recognized
+ * regions; `blocks`/`regions` carry the layout grouping (one `'ungrouped'` block
+ * without a layout model); `image` is the frame all coordinates are relative to
+ * — the corrected page after any orientation/dewarp pass, or the input.
+ * @category Types
+ * @typeParam L The layout region class-label type.
+ */
+export type OcrResult = {
+ readonly detections: OcrDetection[];
+ readonly blocks: DocumentBlock[];
+ readonly regions: ObjectDetection<'xyxy', L>[];
+ readonly image: ImageBuffer;
+};
+
+const RECOGNIZER_NORM: NormalizeOptions = { alpha: 1 / 127.5, beta: -1 }; // (x/255 - 0.5)/0.5 -> [-1, 1]
+const RECOGNIZER_PAD_VALUE = 128; // neutral gray
+const DEFAULT_ORIENTATION_MIN_CONFIDENCE = 0.85;
+
+// Default layout-label conventions (PP-DocLayout); override via OcrModel.
+const DEFAULT_VISUAL_LABELS = ['image', 'chart', 'seal'];
+const DEFAULT_TABLE_LABEL = 'table';
+
+function makeBlock(
+ regionType: L | 'ungrouped',
+ bbox: BoundingBox<'xyxy'>,
+ layoutConfidence: number,
+ lines: OcrDetection[],
+ isTable: boolean
+): DocumentBlock {
+ 'worklet';
+ const sorted = orderByReadingOrder(lines);
+ return {
+ regionType,
+ bbox,
+ layoutConfidence,
+ isTable,
+ lines: sorted,
+ text: sorted.map((l) => l.text).join('\n'),
+ };
+}
+
+/**
+ * Creates the OCR runner for detect → recognize models (EasyOCR / PaddleOCR),
+ * optionally composed with a layout detector and document-helper models. Per run:
+ * orientation → dewarp → (layout ? per-region : whole-page) OCR → reading-ordered
+ * blocks, with table regions filling their recognized structure.
+ * @category Typescript API
+ * @typeParam L The layout region class-label type.
+ * @param config Model path + OCR options + optional layout / document models.
+ * @param runtime Optional worklet runtime thread.
+ * @returns A promise resolving to recognition and disposal controls.
+ */
+export async function createOcr(
+ config: OcrModel,
+ runtime?: WorkletRuntime
+): Promise<{
+ dispose: () => void;
+ runOcr: (input: ImageBuffer, options?: RunOcrOptions) => Promise>;
+ runOcrWorklet: (input: ImageBuffer, options?: RunOcrOptions) => OcrResult;
+}> {
+ const { modelPath, ocrOpts } = config;
+ const model = await wrapAsync(loadModel, runtime)(modelPath);
+
+ // Contract validation can throw; a bad config must not leak the model.
+ let engine!: OcrEngine;
+ try {
+ const contract = resolveOcrContract(model, ocrOpts.charset, ocrOpts.extractBoxes);
+ engine = {
+ model,
+ extractBoxes: ocrOpts.extractBoxes,
+ det: { ...contract.det, norm: ocrOpts.detectorNorm ?? IMAGENET_NORM },
+ rec: {
+ ...contract.rec,
+ norm: ocrOpts.recognizerNorm ?? RECOGNIZER_NORM,
+ padValue: ocrOpts.recognizerPadValue ?? RECOGNIZER_PAD_VALUE,
+ padMode: ocrOpts.recognizerPadMode ?? 'constant',
+ },
+ charset: contract.charset,
+ minConfidence: ocrOpts.minConfidence ?? 0,
+ decode: ocrOpts.decode,
+ };
+ } catch (e) {
+ model.dispose();
+ throw e;
+ }
+
+ // Optional document stages. A later stage failing to build must not leak the
+ // ones already built (including the OCR model).
+ let layout: Awaited>> | null = null;
+ let documentModels: Awaited> | null = null;
+ try {
+ layout = config.layout ? await createObjectDetector<'xyxy', L>(config.layout, runtime) : null;
+ documentModels = config.documentModels
+ ? await createDocumentModels(config.documentModels, runtime)
+ : null;
+ } catch (e) {
+ layout?.dispose();
+ model.dispose();
+ throw e;
+ }
+
+ const dm = config.documentModels;
+ const hasTable = !!dm?.table;
+ const defaultOrientation = !!dm?.orientation;
+ const defaultDewarp = !!dm?.dewarp;
+ const orientationMinConfidence =
+ dm?.orientationMinConfidence ?? DEFAULT_ORIENTATION_MIN_CONFIDENCE;
+ const visualLabels = config.visualLabels ?? DEFAULT_VISUAL_LABELS;
+ const tableLabel = config.tableLabel ?? DEFAULT_TABLE_LABEL;
+
+ const dispose = () => {
+ model.dispose();
+ layout?.dispose();
+ documentModels?.dispose();
+ };
+
+ const runOcrWorklet = (input: ImageBuffer, options?: RunOcrOptions): OcrResult => {
+ 'worklet';
+ // `options` (RunOcrOptions) is structurally a superset of OcrPassOptions, so
+ // the engine takes it directly — the orientation/dewarp fields it ignores.
+ const useOrientation = !!documentModels && (options?.orientation ?? defaultOrientation);
+ const useDewarp = !!documentModels && (options?.dewarp ?? defaultDewarp);
+ // Tables are on whenever the model supports them (unlike orientation/dewarp,
+ // which are opt-in) — a per-run `tables: false` skips the recognizer.
+ const useTables = hasTable && (options?.tables ?? true);
+
+ // Orientation / dewarp produce the corrected frame all coordinates are
+ // relative to; without them the frame is the input unchanged. When a corrected
+ // page tensor is produced it is kept alive (as `correctedPage`) so the
+ // whole-page pass can read it directly instead of round-tripping through the
+ // materialized `img` buffer; the outer `finally` disposes it.
+ let img = input;
+ let correctedPage: Tensor | null = null;
+ let pw = input.width;
+ let ph = input.height;
+ try {
+ if ((useOrientation || useDewarp) && documentModels) {
+ const ch = FORMAT_CHANNELS[input.format];
+ let changed = false;
+ correctedPage = tensor('uint8', [input.height, input.width, ch]);
+ correctedPage.setData(input.data);
+ if (useOrientation) {
+ const orientation = documentModels.detectOrientationWorklet(correctedPage, input.format);
+ const deg = ((360 - orientation.rotationCW) % 360) as 0 | 90 | 180 | 270;
+ if (deg !== 0 && orientation.confidence >= orientationMinConfidence) {
+ const swap = deg === 90 || deg === 270;
+ const rotated = tensor('uint8', [swap ? pw : ph, swap ? ph : pw, ch]);
+ try {
+ rotate(correctedPage, rotated, deg);
+ } catch (e) {
+ rotated.dispose(); // rotate threw before we adopted `rotated`
+ throw e;
+ }
+ correctedPage.dispose();
+ correctedPage = rotated;
+ changed = true;
+ if (swap) {
+ [pw, ph] = [ph, pw];
+ }
+ }
+ }
+ if (useDewarp) {
+ // dewarp returns the input tensor unchanged when it declines the warp.
+ const dewarped = documentModels.dewarpWorklet(correctedPage, input.format);
+ if (dewarped !== correctedPage) {
+ correctedPage.dispose();
+ correctedPage = dewarped;
+ changed = true;
+ }
+ }
+ // Only materialize a new frame when a correction was actually applied;
+ // otherwise the input buffer is already that frame.
+ if (changed) {
+ const out = new Uint8Array(pw * ph * ch);
+ correctedPage.getData(out);
+ img = { data: out, width: pw, height: ph, format: input.format, layout: input.layout };
+ }
+ }
+
+ if (!layout) {
+ const detections = correctedPage
+ ? runOcrPassOnTensor(engine, correctedPage, pw, ph, input.format, options)
+ : runOcrPass(engine, img, options);
+ const blocks = detections.length
+ ? [
+ makeBlock(
+ 'ungrouped',
+ boundsOfPoints(
+ detections.flatMap((d) => d.quad),
+ 'xyxy'
+ ),
+ 1,
+ detections,
+ false
+ ),
+ ]
+ : [];
+ return { detections, blocks, regions: [], image: img };
+ }
+
+ // The layout path crops each region natively out of a live page tensor,
+ // straight into the recognizer — no JS crop + re-upload per region. Reuse
+ // the corrected page tensor if we made one; otherwise upload `img` once.
+ // Ownership stays with `correctedPage` so the outer `finally` frees it.
+ const ch = FORMAT_CHANNELS[img.format];
+ if (!correctedPage) {
+ correctedPage = tensor('uint8', [img.height, img.width, ch]);
+ correctedPage.setData(img.data);
+ }
+ const pageTensor = correctedPage;
+
+ const regions = layout.detectObjectsWorklet(img);
+ const blocks: DocumentBlock[] = [];
+ const detections: OcrDetection[] = [];
+ for (const region of regions) {
+ if (visualLabels.includes(String(region.label))) {
+ continue;
+ }
+ const isTable = String(region.label) === tableLabel;
+ const xmin = Math.max(0, Math.min(Math.round(region.box.xmin), img.width));
+ const ymin = Math.max(0, Math.min(Math.round(region.box.ymin), img.height));
+ const xmax = Math.max(0, Math.min(Math.round(region.box.xmax), img.width));
+ const ymax = Math.max(0, Math.min(Math.round(region.box.ymax), img.height));
+ if (xmax <= xmin || ymax <= ymin) {
+ continue;
+ }
+ // Each region is OCR'd on its own crop for better dense-page recall; the
+ // lines are offset back to page coordinates. The table encoder reads the
+ // same crop tensor, so it stays alive until the region is fully handled.
+ const cropT = tensor('uint8', [ymax - ymin, xmax - xmin, ch]);
+ try {
+ crop(pageTensor, cropT, { format: 'xyxy', xmin, ymin, xmax, ymax });
+ const lines = runOcrPassOnTensor(
+ engine,
+ cropT,
+ xmax - xmin,
+ ymax - ymin,
+ img.format,
+ options
+ ).map((d) => ({
+ ...d,
+ quad: d.quad.map((p) => ({ x: p.x + xmin, y: p.y + ymin })),
+ }));
+ if (lines.length === 0 && !isTable) {
+ continue;
+ }
+ detections.push(...lines);
+ let block = makeBlock(region.label, region.box, region.confidence, lines, isTable);
+ if (isTable && documentModels && useTables) {
+ const skeleton = documentModels.recognizeTableWorklet(cropT, img.format);
+ block = { ...block, tableHtml: fillTableCells(skeleton, block.lines) };
+ }
+ blocks.push(block);
+ } finally {
+ cropT.dispose();
+ }
+ }
+ // Order blocks with the same column-aware reading order as the flat
+ // detections, so the two views of a multi-column page agree (a naive
+ // y-then-x sort interleaves columns).
+ const orderedBlocks = orderByReadingOrder(
+ blocks.map((block) => ({ block, quad: quadFromBounds(block.bbox) }))
+ ).map((w) => w.block);
+ return {
+ detections: orderByReadingOrder(detections),
+ blocks: orderedBlocks,
+ regions,
+ image: img,
+ };
+ } finally {
+ correctedPage?.dispose();
+ }
+ };
+
+ const runOcr = wrapAsync(runOcrWorklet, runtime);
+
+ return { runOcr, runOcrWorklet, dispose };
+}
diff --git a/packages/react-native-executorch/src/extensions/cv/tasks/preprocessing.ts b/packages/react-native-executorch/src/extensions/cv/tasks/preprocessing.ts
index 3569210481..202fe71999 100644
--- a/packages/react-native-executorch/src/extensions/cv/tasks/preprocessing.ts
+++ b/packages/react-native-executorch/src/extensions/cv/tasks/preprocessing.ts
@@ -1,7 +1,7 @@
import { tensor, type Tensor } from '../../../core/tensor';
import { matchShape } from '../../../core/modelSchema';
-import type { ImageBuffer } from '../image';
+import type { ImageBuffer, ImageFormat } from '../image';
import {
type ResizeMode,
type InterpolationMethod,
@@ -56,6 +56,16 @@ export function createImagePreprocessor(
* data.
*/
process: (input: ImageBuffer) => Tensor;
+ /**
+ * Like {@link process}, but reads from a full-res image tensor (`[H, W, C]`,
+ * uint8) already on-device instead of an `ImageBuffer`, avoiding the raw-data
+ * copy. `format` supplies the source channel count and color conversion. The
+ * returned tensor is preprocessor-managed (do not dispose).
+ * @param src The full-res source image tensor in HWC layout.
+ * @param format The pixel format of `src` (for channels + color conversion).
+ * @returns A reference to the managed output tensor.
+ */
+ processTensor: (src: Tensor, format: ImageFormat) => Tensor;
/**
* Releases all allocated native resources.
*/
@@ -114,5 +124,27 @@ export function createImagePreprocessor(
return tOutput;
};
- return { process, dispose };
+ const processTensor = (src: Tensor, format: ImageFormat): Tensor => {
+ 'worklet';
+ const numChannels = FORMAT_CHANNELS[format];
+ const colorCode = FORMAT_CONVERSION[format].rgb;
+ const tResize = tensor('uint8', [targetH, targetW, numChannels]);
+ try {
+ src
+ .through(resize, tResize, {
+ mode: resizeMode,
+ interpolation: interpolation,
+ padValue: padValue,
+ })
+ .throughIf(colorCode !== null, cvtColor, tColor, colorCode!)
+ .through(toChannelsFirst, tChanFirst)
+ .through(normalize, tNorm, { alpha, beta })
+ .copyTo(tOutput);
+ } finally {
+ tResize.dispose();
+ }
+ return tOutput;
+ };
+
+ return { process, processTensor, dispose };
}
diff --git a/packages/react-native-executorch/src/hooks/useOcr.ts b/packages/react-native-executorch/src/hooks/useOcr.ts
new file mode 100644
index 0000000000..aa7749d850
--- /dev/null
+++ b/packages/react-native-executorch/src/hooks/useOcr.ts
@@ -0,0 +1,66 @@
+import { createOcr, type OcrModel } from '../extensions/cv/tasks/ocr/ocr';
+import { useResourceDownload } from './useResourceDownload';
+import { useModel } from './useModel';
+
+// Swap a model spec's hosted `modelPath` for its downloaded local path. Returns
+// undefined when the spec is absent (an optional model) or its path hasn't
+// finished downloading yet.
+const localize = (
+ spec: M | undefined,
+ localPath: string | undefined
+): M | undefined => (spec && localPath ? { ...spec, modelPath: localPath } : undefined);
+
+/**
+ * React hook for the OCR pipeline (EasyOCR / PaddleOCR), optionally composed with
+ * layout detection and document-helper models (orientation/dewarp/table). It
+ * downloads and loads every enabled model, tracks combined progress and errors,
+ * instantiates the task runner, and cleans up native memory on unmount or config
+ * change. Heavy work runs on a worklet thread; `runOcr` resolves with the
+ * recognized regions, layout blocks, and corrected frame.
+ * @category Hooks
+ * @typeParam L The layout region class-label type (from the `layout` model).
+ * @param config OCR model + optional layout / document models. Use a preset from
+ * `models.ocr.*` (plus `models.layoutDetection.*` / `models.documentModels.*`).
+ * @param options Optional flags. `preventLoad` defers downloading/loading the
+ * models until set to `false`.
+ * @returns Readiness flags, combined download progress, and the `runOcr` /
+ * `runOcrWorklet` runners.
+ */
+export function useOcr(config: OcrModel, options?: { preventLoad?: boolean }) {
+ const ocrDl = useResourceDownload(config.modelPath, options?.preventLoad);
+ const layoutDl = useResourceDownload(config.layout?.modelPath, options?.preventLoad);
+ const documentModelsDl = useResourceDownload(
+ config.documentModels?.modelPath,
+ options?.preventLoad
+ );
+
+ const layout = localize(config.layout, layoutDl.localPath);
+ const documentModels = localize(config.documentModels, documentModelsDl.localPath);
+ const ready =
+ !!ocrDl.localPath &&
+ (!config.layout || !!layout) &&
+ (!config.documentModels || !!documentModels);
+ const localConfig: OcrModel | null = ready
+ ? { ...config, modelPath: ocrDl.localPath!, layout, documentModels }
+ : null;
+
+ const { model, error } = useModel(createOcr, localConfig, [
+ ocrDl.localPath,
+ layoutDl.localPath,
+ documentModelsDl.localPath,
+ ]);
+
+ const downloads = [
+ ocrDl,
+ ...(config.layout ? [layoutDl] : []),
+ ...(config.documentModels ? [documentModelsDl] : []),
+ ];
+
+ return {
+ isReady: !!model,
+ error: downloads.map((d) => d.downloadError).find(Boolean) || error,
+ downloadProgress: Math.min(...downloads.map((d) => d.downloadProgress)),
+ runOcr: model?.runOcr,
+ runOcrWorklet: model?.runOcrWorklet,
+ };
+}
diff --git a/packages/react-native-executorch/src/index.ts b/packages/react-native-executorch/src/index.ts
index ea9bca1a96..c8c75f14e7 100644
--- a/packages/react-native-executorch/src/index.ts
+++ b/packages/react-native-executorch/src/index.ts
@@ -10,6 +10,7 @@ export * from './hooks/useTextEmbedder';
export * from './hooks/useImageEmbedder';
export * from './hooks/useVoiceActivityDetector';
export * from './hooks/useTextToImage';
+export * from './hooks/useOcr';
export * from './hooks/useResourceDownload';
export * from './hooks/useModel';
@@ -29,6 +30,12 @@ export * from './extensions/cv/tasks/sdxsTextToImage';
export * from './extensions/nlp/tasks/tokenization';
export * from './extensions/nlp/tasks/textEmbedding';
export * from './extensions/speech/tasks/fsmnVoiceActivityDetection';
+export * from './extensions/cv/tasks/ocr/ocr';
+export * from './extensions/cv/tasks/ocr/detectors';
+export type { Quad } from './extensions/cv/ops/quad';
+export type { NormalizeOptions } from './extensions/cv/ops/image';
+export type { DocLayoutLabel } from './constants';
+export type { DocumentModelsConfig, TableConfig } from './extensions/cv/tasks/ocr/documentModels';
// Core primitives — for library builders and power users
export { tensor } from './core/tensor';
@@ -40,6 +47,9 @@ export type {
ModelInput,
ModelOutput,
TensorMeta,
+ DimRange,
+ EnumeratedShapes,
+ InputShapeConstraint,
ModelMethodMeta,
ExecuTorchTag,
} from './core/model';
diff --git a/packages/react-native-executorch/src/models.ts b/packages/react-native-executorch/src/models.ts
index 1665f724d5..d9f9ec5763 100644
--- a/packages/react-native-executorch/src/models.ts
+++ b/packages/react-native-executorch/src/models.ts
@@ -8,6 +8,9 @@ import type { ImageEmbedderModel } from './extensions/cv/tasks/imageEmbedding';
import type { SdxsTextToImageModel } from './extensions/cv/tasks/sdxsTextToImage';
import type { TextEmbedderModel } from './extensions/nlp/tasks/textEmbedding';
import type { FsmnVadModel } from './extensions/speech/tasks/fsmnVoiceActivityDetection';
+import type { OcrModel, OcrModelOptions } from './extensions/cv/tasks/ocr/ocr';
+import { craftExtractBoxes, dbnetExtractBoxes } from './extensions/cv/tasks/ocr/detectors';
+import type { DocumentModelsConfig } from './extensions/cv/tasks/ocr/documentModels';
import {
IMAGENET_NORM,
IMAGENET1K_LABELS,
@@ -16,12 +19,17 @@ import {
COCO_CLASSES_YOLO,
BLAZEFACE_LANDMARKS,
COCO_LANDMARKS,
+ DOC_LAYOUT_LABELS,
+ SLANET_STRUCTURE_VOCAB,
+ alphabets,
+ PPOCR_SYMBOLS,
type ImageNet1KLabel,
type PascalVocLabel,
type CocoClass,
type CocoClassYolo,
type BlazeFaceLandmark,
type CocoLandmark,
+ type DocLayoutLabel,
} from './constants';
const BASE_URL = 'https://huggingface.co/software-mansion/react-native-executorch';
@@ -624,6 +632,106 @@ const SDXS_512_DREAMSHAPER_COREML_FP16: SdxsTextToImageModel = {
// =============================================================================
const ALL_MINILM_L6_V2_TOKENIZER = `${BASE_URL}-all-MiniLM-L6-v2/${VERSION_TAG}/tokenizer.json`;
+// =============================================================================
+// OCR
+// =============================================================================
+// Both OCR families are exported WITHOUT baked input normalization and expect
+// standard ImageNet RGB norm applied client-side (see IMAGENET_NORM).
+// The per-language charset is added by each model entry below.
+const EASYOCR_OPTS: Omit = {
+ extractBoxes: craftExtractBoxes,
+ detectorNorm: IMAGENET_NORM,
+ recognizerPadMode: 'cornerMean',
+};
+
+const PADDLE_PPOCRV6_OPTS: OcrModelOptions = {
+ extractBoxes: dbnetExtractBoxes,
+ minConfidence: 0.5,
+ charset: PPOCR_SYMBOLS,
+ detectorNorm: IMAGENET_NORM,
+};
+
+// OCR-family repos tag as `0.9.0` (no `v` prefix — they tag differently from the
+// classic model repos that use VERSION_TAG). The recognizer repos and the
+// document-pipeline repos (doclayout, paddle-helpers) are versioned separately
+// upstream and can diverge, so they get distinct constants even at the same value.
+const OCR_REVISION = 'resolve/0.9.0';
+const DOC_PIPELINE_REVISION = 'resolve/0.9.0';
+
+const makeEasyOcr = (
+ lang: string,
+ backend: string,
+ charset: string | readonly string[]
+): OcrModel => ({
+ modelPath: `${BASE_URL}-easy-ocr/${OCR_REVISION}/${lang}/EasyOCR_${lang}_${backend}.pte`,
+ ocrOpts: { ...EASYOCR_OPTS, charset },
+});
+const easyOcr = (lang: string, charset: string | readonly string[]) => ({
+ XNNPACK: makeEasyOcr(lang, 'xnnpack', charset),
+ COREML: makeEasyOcr(lang, 'coreml', charset),
+ VULKAN: makeEasyOcr(lang, 'vulkan', charset),
+});
+
+const PADDLE_PPOCRV6_XNNPACK: OcrModel = {
+ modelPath: `${BASE_URL}-pp-ocrv6/${OCR_REVISION}/PP-OCRv6_xnnpack.pte`,
+ ocrOpts: PADDLE_PPOCRV6_OPTS,
+};
+const PADDLE_PPOCRV6_COREML: OcrModel = {
+ modelPath: `${BASE_URL}-pp-ocrv6/${OCR_REVISION}/PP-OCRv6_coreml.pte`,
+ ocrOpts: PADDLE_PPOCRV6_OPTS,
+};
+const PADDLE_PPOCRV6_VULKAN: OcrModel = {
+ modelPath: `${BASE_URL}-pp-ocrv6/${OCR_REVISION}/PP-OCRv6_vulkan.pte`,
+ ocrOpts: PADDLE_PPOCRV6_OPTS,
+};
+
+// =============================================================================
+// Document layout — PP-DocLayoutV3
+// =============================================================================
+const PP_DOCLAYOUT_OPTS = {
+ labels: DOC_LAYOUT_LABELS,
+ boxFormat: 'xyxy' as const,
+ resizeMode: 'stretch' as const,
+ interpolation: 'linear' as const,
+ alpha: 1 / 255.0,
+ beta: 0.0,
+ defaultConfidenceThreshold: 0.3,
+ defaultIouThreshold: 1.0,
+};
+const PP_DOCLAYOUT_XNNPACK: ObjectDetectorModel<'xyxy', DocLayoutLabel> = {
+ modelPath: `${BASE_URL}-pp-doclayout-v3/${DOC_PIPELINE_REVISION}/PP-DocLayoutV3_xnnpack.pte`,
+ opts: PP_DOCLAYOUT_OPTS,
+};
+const PP_DOCLAYOUT_COREML: ObjectDetectorModel<'xyxy', DocLayoutLabel> = {
+ modelPath: `${BASE_URL}-pp-doclayout-v3/${DOC_PIPELINE_REVISION}/PP-DocLayoutV3_coreml.pte`,
+ opts: PP_DOCLAYOUT_OPTS,
+};
+const PP_DOCLAYOUT_VULKAN: ObjectDetectorModel<'xyxy', DocLayoutLabel> = {
+ modelPath: `${BASE_URL}-pp-doclayout-v3/${DOC_PIPELINE_REVISION}/PP-DocLayoutV3_vulkan.pte`,
+ opts: PP_DOCLAYOUT_OPTS,
+};
+
+// =============================================================================
+// Document helper models - PaddleHelpers (orientation / dewarp / table structure)
+// =============================================================================
+const SLANET_TABLE = {
+ structureVocab: SLANET_STRUCTURE_VOCAB,
+ eosTokenId: SLANET_STRUCTURE_VOCAB.indexOf('eos'),
+ maxSteps: 501, // decoder step cap — SLANet's longest table structure
+};
+const PP_HELPERS_XNNPACK: DocumentModelsConfig = {
+ modelPath: `${BASE_URL}-paddle-helpers/${DOC_PIPELINE_REVISION}/PaddleHelpers_xnnpack.pte`,
+ table: SLANET_TABLE,
+};
+const PP_HELPERS_COREML: DocumentModelsConfig = {
+ modelPath: `${BASE_URL}-paddle-helpers/${DOC_PIPELINE_REVISION}/PaddleHelpers_coreml.pte`,
+ table: SLANET_TABLE,
+};
+const PP_HELPERS_VULKAN: DocumentModelsConfig = {
+ modelPath: `${BASE_URL}-paddle-helpers/${DOC_PIPELINE_REVISION}/PaddleHelpers_vulkan.pte`,
+ table: SLANET_TABLE,
+};
+
/**
* Registry of pre-configured ExecuTorch models.
*
@@ -887,4 +995,37 @@ export const models = {
COREML_FP16: SDXS_512_DREAMSHAPER_COREML_FP16,
},
},
+ ocr: {
+ EASYOCR: {
+ ENGLISH: easyOcr('english', alphabets.english),
+ CYRILLIC: easyOcr('cyrillic', alphabets.cyrillic),
+ LATIN: easyOcr('latin', alphabets.latin),
+ JAPANESE: easyOcr('japanese', alphabets.japanese),
+ ZH_SIM: easyOcr('zh_sim', alphabets.zh_sim),
+ KOREAN: easyOcr('korean', alphabets.korean),
+ TELUGU: easyOcr('telugu', alphabets.telugu),
+ KANNADA: easyOcr('kannada', alphabets.kannada),
+ },
+ PADDLE: {
+ PPOCRV6_SMALL: {
+ XNNPACK: PADDLE_PPOCRV6_XNNPACK,
+ VULKAN: PADDLE_PPOCRV6_VULKAN,
+ COREML: PADDLE_PPOCRV6_COREML,
+ },
+ },
+ },
+ layoutDetection: {
+ PP_DOCLAYOUT: {
+ XNNPACK: PP_DOCLAYOUT_XNNPACK,
+ VULKAN: PP_DOCLAYOUT_VULKAN,
+ COREML: PP_DOCLAYOUT_COREML,
+ },
+ },
+ documentModels: {
+ PP_HELPERS: {
+ XNNPACK: PP_HELPERS_XNNPACK,
+ VULKAN: PP_HELPERS_VULKAN,
+ COREML: PP_HELPERS_COREML,
+ },
+ },
};