Speed up cold diffs and wrapped text measurement (#4676)

* perf(app): optimize wrapped measurements and checkpoint native layout experiment

Compute full grapheme geometry only for final wrap fragments. Preserve shaped-text search behavior while bounding additive wrap probes. Keep the Android background layout experiment isolated from the normal diff renderer; native renderer integration and iOS bindings remain unfinished.

* perf(server): reduce cold diff generation work

Keep active diff reads owned across invalidation and wait for the initial watcher inventory before cold generation. Batch patch and blob reads while preserving per-file size isolation, bounded output, and daemon responsiveness. Avoid constructing fallback tokens when full-file highlighting already covers both sides.

* test(server): exercise oversized highlighted diff rejection

The single-line fixture now bypasses highlighting under the long-line guard, so it no longer reaches the structured payload limit. Use short dense lines to keep testing oversized rejection and continued socket usability.

* fix(server): queue fresh diffs for forced reads

A forced request following a repository mutation must not reuse an older active build. Invalidate that result and coalesce waiting callers onto one subsequent build. Give the cache a named object input and completed-value type.

* test(server): share deferred diff loader setup

* test(server): name completed diff cache setup

* refactor(server): use aliases for new diff imports

* fix(server): keep shipped diff imports resolvable by Node

The TypeScript build preserves path aliases without a runtime resolver. Restore relative imports for shipped code and declarations, and correct the server import guideline that prompted the regression.
This commit is contained in:
Mohamed Boudra
2026-09-11 08:07:01 +02:00
committed by GitHub
parent 16d932651c
commit 33a52d0ae7
32 changed files with 1409 additions and 186 deletions
@@ -0,0 +1,2 @@
/android/.cxx/
/android/build/
@@ -0,0 +1,35 @@
# Native diff layout experiment
Android-only Expo module for evaluating background wrapped-text layout. The normal
diff surface does not call this module; it still uses the JavaScript model and
renderer. This is a checkpoint of the layout experiment, not a replacement renderer.
The C++ core owns cell text and wrapped grapheme geometry. Kotlin/JNI supplies the
Android font manager and dispatches preparation through Expo's background queue.
It links the Skia libraries shipped by the installed React Native Skia package.
An iOS binding, renderer integration, and shipping library ownership are unfinished.
## Diagnostic interface
Access the module through `expo.modules.PaseoDiffPrototype` in a development build:
- `create()` returns a numeric handle.
- `prepare(handle, texts, widths, family, fontSize)` asynchronously builds geometry
and returns counts, copy/layout timings, and a partial geometry allocation estimate.
Text and width arrays must have matching lengths. Input is copied; it is not zero-copy.
- `read(handle, startCell, count)` synchronously returns packed numerical geometry
for at most 256 cells. This exists for parity checks, not whole-model transfer.
- `release(handle)` cancels pending work and releases ownership. Release handles
after success or failure; module destruction also cancels and releases all handles.
Each packed cell begins with its index and fragment count. Each fragment contains
UTF-16 start/end offsets, width, and grapheme count, followed by each grapheme's
UTF-16 start/end offsets and width. The caller retains source text.
The experiment accepts normalized cell inputs. It does not parse wire payloads,
construct file/header/review models, apply highlighting, or draw pixels. A renderer
integration must preserve native ownership and request bounded visible data rather
than reconstructing the entire JavaScript graph.
Grapheme property ranges are generated at build time from the installed JavaScript
dependency so segmentation uses the same data as the reference implementation.
@@ -0,0 +1,15 @@
cmake_minimum_required(VERSION 3.22)
project(paseo_diff_prototype)
set(CMAKE_CXX_STANDARD 20)
execute_process(COMMAND node "${CMAKE_CURRENT_SOURCE_DIR}/../cpp/generate-graphemes.mjs" "${CMAKE_CURRENT_BINARY_DIR}/graphemes.h" COMMAND_ERROR_IS_FATAL ANY)
add_library(paseo_diff_prototype SHARED ../cpp/layout.cpp ../cpp/jni.cpp)
target_include_directories(paseo_diff_prototype PRIVATE "${SKIA_PACKAGE}/cpp/skia" "${CMAKE_CURRENT_BINARY_DIR}")
target_compile_definitions(paseo_diff_prototype PRIVATE SK_BUILD_FOR_ANDROID)
target_compile_options(paseo_diff_prototype PRIVATE -O2 -Wall -Wextra)
target_link_options(paseo_diff_prototype PRIVATE "-Wl,-z,max-page-size=16384")
foreach(lib skparagraph skshaper skunicode_core skunicode_icu skia)
add_library(${lib} STATIC IMPORTED)
set_target_properties(${lib} PROPERTIES IMPORTED_LOCATION "${SKIA_PACKAGE}/libs/android/${ANDROID_ABI}/lib${lib}.a")
endforeach()
target_link_libraries(paseo_diff_prototype PRIVATE skparagraph skshaper skunicode_core skunicode_icu skia android log EGL GLESv2)
@@ -0,0 +1,27 @@
apply plugin: 'com.android.library'
group = 'sh.paseo.diffprototype'
version = '0.1.0'
def expoModulesCorePlugin = new File(project(":expo-modules-core").projectDir.absolutePath, "ExpoModulesCorePlugin.gradle")
apply from: expoModulesCorePlugin
applyKotlinExpoModulesCorePlugin()
useCoreDependencies()
useDefaultAndroidSdkVersions()
def skiaPackage = new File(["node", "--print", "require.resolve('@shopify/react-native-skia/package.json')"].execute(null, projectDir).text.trim()).parentFile
android {
namespace "sh.paseo.diffprototype"
defaultConfig {
externalNativeBuild {
cmake {
arguments "-DSKIA_PACKAGE=${skiaPackage.absolutePath}"
cppFlags "-std=c++20 -O2 -fexceptions -frtti"
}
}
}
externalNativeBuild {
cmake { path "CMakeLists.txt" }
}
}
@@ -0,0 +1 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android" />
@@ -0,0 +1,34 @@
package sh.paseo.diffprototype
import expo.modules.kotlin.modules.Module
import expo.modules.kotlin.modules.ModuleDefinition
// Experimental binding only. The layout engine has no JVM or platform dependency.
class PaseoDiffPrototypeModule : Module() {
companion object {
init { System.loadLibrary("paseo_diff_prototype") }
}
private external fun createDocument(): Double
private external fun prepareDocument(id: Double, texts: Array<String>, widths: DoubleArray, family: String, size: Double): DoubleArray
private external fun readDocument(id: Double, start: Int, count: Int): DoubleArray
private external fun releaseDocument(id: Double)
private external fun releaseAllDocuments()
override fun definition() = ModuleDefinition {
Name("PaseoDiffPrototype")
Function("create") { createDocument() }
// Expo dispatches this non-suspending AsyncFunction on its background queue.
AsyncFunction("prepare") { id: Double, texts: List<String>, widths: List<Double>, family: String, size: Double ->
val stats = prepareDocument(id, texts.toTypedArray(), widths.toDoubleArray(), family, size)
mapOf(
"cells" to stats[0], "fragments" to stats[1], "graphemes" to stats[2],
"copyMs" to stats[3], "layoutMs" to stats[4], "geometryBytes" to stats[5]
)
}
Function("read") { id: Double, start: Int, count: Int -> readDocument(id, start, count) }
Function("release") { id: Double -> releaseDocument(id) }
OnDestroy { releaseAllDocuments() }
}
}
@@ -0,0 +1,18 @@
import { writeFileSync } from "node:fs";
import { isExtendingChar } from "@marijn/find-cluster-break";
// Use the installed JS engine's Unicode property data, not a different ICU version.
const ranges = [];
let start = null;
for (let code = 0; code <= 0x110000; code++) {
const extending = code < 0x110000 && isExtendingChar(code);
if (extending && start === null) start = code;
if (!extending && start !== null) {
ranges.push(`{${start},${code}}`);
start = null;
}
}
writeFileSync(
process.argv[2],
`#pragma once\n#include <array>\n#include <utility>\ninline constexpr std::array<std::pair<int,int>,${ranges.length}> extendingRanges = {{${ranges.join(",")}}};\n`,
);
@@ -0,0 +1,136 @@
#include "layout.h"
#include <jni.h>
#include <chrono>
#include <cmath>
#include <memory>
#include <mutex>
#include <stdexcept>
#include <unordered_map>
#include "include/ports/SkFontMgr_android.h"
#include "modules/skunicode/include/SkUnicode.h"
namespace {
using Clock = std::chrono::steady_clock;
using paseo::diff::Document;
struct Job {
std::atomic<bool> cancelled{false};
std::atomic<bool> started{false};
std::shared_ptr<const Document> document;
};
std::mutex mutex;
std::unordered_map<uint64_t, std::shared_ptr<Job>> jobs;
uint64_t nextId = 0;
double elapsed(Clock::time_point start) {
return std::chrono::duration<double, std::milli>(Clock::now() - start).count();
}
std::shared_ptr<Job> findJob(double id) {
std::lock_guard lock(mutex);
const auto found = jobs.find(static_cast<uint64_t>(id));
if (found == jobs.end()) throw std::runtime_error("Unknown or released layout");
return found->second;
}
std::u16string copyString(JNIEnv* env, jstring source) {
if (!source) throw std::runtime_error("Missing text");
const jsize length = env->GetStringLength(source);
const jchar* chars = env->GetStringChars(source, nullptr);
if (!chars) throw std::runtime_error("Could not copy text");
std::u16string result(reinterpret_cast<const char16_t*>(chars), length);
env->ReleaseStringChars(source, chars);
return result;
}
jdoubleArray numbers(JNIEnv* env, const std::vector<double>& values) {
auto result = env->NewDoubleArray(values.size());
if (result) env->SetDoubleArrayRegion(result, 0, values.size(), values.data());
return result;
}
void fail(JNIEnv* env, const std::exception& error) {
env->ThrowNew(env->FindClass("java/lang/IllegalStateException"), error.what());
}
} // namespace
#define METHOD(name) Java_sh_paseo_diffprototype_PaseoDiffPrototypeModule_##name
extern "C" JNIEXPORT jdouble JNICALL METHOD(createDocument)(JNIEnv*, jobject) {
std::lock_guard lock(mutex);
const auto id = ++nextId;
jobs.emplace(id, std::make_shared<Job>());
return id;
}
extern "C" JNIEXPORT jdoubleArray JNICALL METHOD(prepareDocument)(
JNIEnv* env, jobject, jdouble id, jobjectArray texts, jdoubleArray widths,
jstring family, jdouble size) {
try {
auto job = findJob(id);
if (job->started.exchange(true)) throw std::runtime_error("Layout already started");
const auto copyStart = Clock::now();
const jsize count = env->GetArrayLength(texts);
if (count > 100000 || env->GetArrayLength(widths) != count || !std::isfinite(size) || size <= 0 || size > 100)
throw std::runtime_error("Invalid layout input");
std::vector<double> available(count);
env->GetDoubleArrayRegion(widths, 0, count, available.data());
auto document = std::make_shared<Document>();
document->cells.reserve(count);
size_t units = 0;
for (jsize i = 0; i < count; i++) {
if (job->cancelled.load()) throw std::runtime_error("Layout cancelled");
if (!std::isfinite(available[i]) || available[i] <= 0) throw std::runtime_error("Invalid width");
auto text = static_cast<jstring>(env->GetObjectArrayElement(texts, i));
auto copied = copyString(env, text);
env->DeleteLocalRef(text);
units += copied.size();
if (units > 16000000) throw std::runtime_error("Prototype input limit exceeded");
document->cells.push_back({std::move(copied), available[i], {}});
}
const auto fontFamily = SkUnicode::convertUtf16ToUtf8(copyString(env, family));
const double copyMs = elapsed(copyStart);
const auto layoutStart = Clock::now();
paseo::diff::prepare(*document, SkFontMgr_New_Android(nullptr), fontFamily.c_str(), size, job->cancelled);
const double layoutMs = elapsed(layoutStart);
{
std::lock_guard lock(mutex);
if (job->cancelled.load()) throw std::runtime_error("Layout cancelled");
job->document = document;
}
return numbers(env, {static_cast<double>(document->cells.size()),
static_cast<double>(document->fragmentCount), static_cast<double>(document->graphemeCount),
copyMs, layoutMs, static_cast<double>(document->geometryBytes)});
} catch (const std::exception& error) {
fail(env, error);
return nullptr;
}
}
extern "C" JNIEXPORT jdoubleArray JNICALL METHOD(readDocument)(
JNIEnv* env, jobject, jdouble id, jint start, jint count) {
try {
const auto job = findJob(id);
std::shared_ptr<const Document> document;
{
std::lock_guard lock(mutex);
document = job->document;
}
if (!document) throw std::runtime_error("Layout is not ready");
if (start < 0 || count < 0 || count > 256 || static_cast<size_t>(start) > document->cells.size())
throw std::runtime_error("Invalid viewport range");
return numbers(env, paseo::diff::read(*document, start, count));
} catch (const std::exception& error) {
fail(env, error);
return nullptr;
}
}
extern "C" JNIEXPORT void JNICALL METHOD(releaseDocument)(JNIEnv*, jobject, jdouble id) {
std::lock_guard lock(mutex);
const auto found = jobs.find(static_cast<uint64_t>(id));
if (found == jobs.end()) return;
found->second->cancelled.store(true);
jobs.erase(found);
}
extern "C" JNIEXPORT void JNICALL METHOD(releaseAllDocuments)(JNIEnv*, jobject) {
std::lock_guard lock(mutex);
for (auto& [id, job] : jobs) job->cancelled.store(true);
jobs.clear();
}
@@ -0,0 +1,277 @@
#include "layout.h"
#include "graphemes.h"
#include <algorithm>
#include <cmath>
#include <numeric>
#include <stdexcept>
#include <unordered_map>
#include <unordered_set>
#include "include/core/SkFont.h"
#include "modules/skparagraph/include/FontCollection.h"
#include "modules/skparagraph/include/Paragraph.h"
#include "modules/skparagraph/include/ParagraphBuilder.h"
#include "modules/skunicode/include/SkUnicode.h"
namespace paseo::diff {
namespace {
namespace para = skia::textlayout;
int codePoint(const std::u16string& text, size_t pos) {
int code = text[pos];
if (code >= 0xd800 && code < 0xdc00 && pos + 1 < text.size()) {
int low = text[pos + 1];
if (low >= 0xdc00 && low < 0xe000) return ((code - 0xd800) << 10) + low - 0xdc00 + 0x10000;
}
return code;
}
bool extending(int code) {
const auto found = std::upper_bound(extendingRanges.begin(), extendingRanges.end(), code,
[](int value, const auto& range) { return value < range.first; });
return found != extendingRanges.begin() && code < std::prev(found)->second;
}
bool regional(int code) { return code >= 0x1f1e6 && code <= 0x1f1ff; }
// Same forward-cluster rules as @marijn/find-cluster-break, including ZWJ/RI behavior.
std::vector<Grapheme> segment(const std::u16string& text) {
std::vector<Grapheme> result;
size_t pos = 0;
size_t column = 0;
while (pos < text.size()) {
const auto start = pos;
int previous = codePoint(text, pos);
pos += previous < 0x10000 ? 1 : 2;
while (pos < text.size()) {
const int next = codePoint(text, pos);
if (previous == 0x200d || next == 0x200d || extending(next)) {
pos += next < 0x10000 ? 1 : 2;
previous = next;
} else if (regional(next)) {
size_t count = 0;
for (int index = static_cast<int>(pos) - 2; index >= 0 && regional(codePoint(text, index)); index -= 2) count++;
if (count % 2 == 0) break;
pos += 2;
} else {
break;
}
}
auto display = text.substr(start, pos - start);
const bool tab = display == u"\t";
const size_t spaces = tab ? 4 - column % 4 : 0;
if (tab) display.assign(spaces, u' ');
result.push_back({static_cast<int>(start), static_cast<int>(pos), std::move(display)});
column += tab ? spaces : 1;
}
return result;
}
bool needsShaping(const std::u16string& text) {
for (const auto c : text) if ((c < 0x20 || c > 0x7e) && c != u'\t') return true;
for (const auto* pair : {u"==", u"!=", u"=>", u"<=", u">=", u"->", u"::", u"++", u"--"}) {
if (text.find(pair) != std::u16string::npos) return true;
}
return false;
}
bool cleanBoundary(const Grapheme& grapheme) {
for (const auto c : grapheme.text) {
if (c < 0x20 || c > 0x7e || std::u16string_view(u"=!<>+-:&|*/~^?.").find(c) != std::u16string_view::npos) return false;
}
return true;
}
size_t chunkEnd(const std::vector<Grapheme>& graphemes, size_t start, size_t end) {
const size_t limit = start + 64;
if (limit >= end) return end;
const size_t floor = std::max(start + 1, limit - 16);
for (size_t seam = limit; seam > floor; seam--) {
if (cleanBoundary(graphemes[seam - 1]) && cleanBoundary(graphemes[seam])) return seam;
}
return limit;
}
class Measurer {
public:
Measurer(sk_sp<SkFontMgr> manager, const std::string& family, float size,
const std::atomic<bool>& cancelled) : cancelled_(cancelled) {
families_ = {SkString(family), SkString("Menlo"), SkString("SF Mono"), SkString("monospace"), SkString("System")};
std::unordered_set<std::string> available;
for (int i = 0; i < manager->countFamilies(); i++) {
SkString name;
manager->getFamilyName(i, &name);
available.insert(name.c_str());
}
std::string primary = "System";
for (const auto& name : families_) {
if (available.contains(name.c_str())) { primary = name.c_str(); break; }
}
font_ = SkFont(manager->matchFamilyStyle(primary.c_str(), SkFontStyle()), size);
fonts_ = sk_make_sp<para::FontCollection>();
fonts_->setDefaultFontManager(manager);
fonts_->enableFontFallback();
para::StrutStyle strut;
strut.setStrutEnabled(true);
strut.setForceStrutHeight(true);
strut.setFontFamilies(families_);
strut.setFontSize(size);
strut.setHeight(std::round(size * 1.5) / size);
strut.setHeightOverride(true);
strut.setHalfLeading(true);
style_.setMaxLines(1);
style_.setStrutStyle(strut);
textStyle_.setFontFamilies(families_);
textStyle_.setFontSize(size);
textStyle_.setColor(SK_ColorBLACK);
}
double measure(const std::vector<Grapheme>& graphemes, size_t start, size_t end,
std::vector<double>* advances = nullptr) {
double base = 0;
for (size_t chunkStart = start; chunkStart < end;) {
if (cancelled_.load()) throw std::runtime_error("Layout cancelled");
const size_t chunkLimit = chunkEnd(graphemes, chunkStart, end);
std::u16string text;
for (size_t i = chunkStart; i < chunkLimit; i++) text += graphemes[i].text;
double width = 0;
if (!needsShaping(text) && hasEveryGlyph(text)) {
for (size_t i = chunkStart; i < chunkLimit; i++) {
width += additiveWidth(graphemes[i].text);
if (advances) advances->push_back(base + width);
}
} else {
auto paragraph = shape(text);
if (advances) {
size_t offset = 0;
for (size_t i = chunkStart; i < chunkLimit; i++) {
offset += graphemes[i].text.size();
width = paragraphWidth(*paragraph, offset);
advances->push_back(base + width);
}
} else {
width = paragraphWidth(*paragraph, text.size());
}
}
base += width;
chunkStart = chunkLimit;
}
return base;
}
private:
bool hasEveryGlyph(const std::u16string& text) {
for (const auto c : text) {
auto [it, inserted] = glyphs_.try_emplace(c, false);
if (inserted) it->second = font_.unicharToGlyph(c) != 0;
if (!it->second) return false;
}
return true;
}
double additiveWidth(const std::u16string& text) {
auto [it, inserted] = widths_.try_emplace(text, 0);
if (inserted) {
std::vector<SkGlyphID> glyphs(font_.countText(text.data(), text.size() * 2, SkTextEncoding::kUTF16));
font_.textToGlyphs(text.data(), text.size() * 2, SkTextEncoding::kUTF16, SkSpan(glyphs));
std::vector<SkScalar> widths(glyphs.size());
font_.getWidthsBounds(SkSpan(glyphs), SkSpan(widths), {}, nullptr);
// Match RNSkia 2.2.12 getTextWidth's integer accumulator exactly.
it->second = std::accumulate(widths.begin(), widths.end(), 0);
}
return it->second;
}
std::unique_ptr<para::Paragraph> shape(const std::u16string& text) {
auto builder = para::ParagraphBuilder::make(style_, fonts_);
builder->pushStyle(textStyle_);
const auto utf8 = SkUnicode::convertUtf16ToUtf8(text);
builder->addText(utf8.c_str(), utf8.size());
builder->pop();
auto paragraph = builder->Build();
paragraph->layout(100000);
return paragraph;
}
static double paragraphWidth(para::Paragraph& paragraph, size_t end) {
double width = 0;
for (const auto& box : paragraph.getRectsForRange(0, end, para::RectHeightStyle::kTight, para::RectWidthStyle::kTight)) {
// JS adds the two float-valued properties in double precision.
width = std::max(width, static_cast<double>(box.rect.x()) + box.rect.width());
}
return width;
}
SkFont font_;
std::vector<SkString> families_;
sk_sp<para::FontCollection> fonts_;
para::ParagraphStyle style_;
para::TextStyle textStyle_;
std::unordered_map<char16_t, bool> glyphs_;
std::unordered_map<std::u16string, double> widths_;
const std::atomic<bool>& cancelled_;
};
} // namespace
void prepare(Document& document, sk_sp<SkFontMgr> fonts, const std::string& family, float size,
const std::atomic<bool>& cancelled) {
Measurer measure(std::move(fonts), family, size, cancelled);
for (auto& cell : document.cells) {
if (cancelled.load()) throw std::runtime_error("Layout cancelled");
auto graphemes = segment(cell.text);
const bool bounded = !needsShaping(cell.text);
size_t start = 0;
do {
size_t fitting = graphemes.size();
if (!graphemes.empty()) {
size_t low = start + 1;
size_t high = bounded ? std::min(graphemes.size(), start + 64) : graphemes.size();
fitting = low;
if (bounded) {
while (measure.measure(graphemes, start, high) <= cell.availableWidth) {
fitting = high;
low = high + 1;
if (high == graphemes.size()) break;
high = std::min(graphemes.size(), start + (high - start) * 2);
}
}
while (low <= high) {
const size_t middle = (low + high) / 2;
const double width = measure.measure(graphemes, start, middle);
if (width <= cell.availableWidth || middle == start + 1) {
fitting = middle;
low = middle + 1;
} else {
high = middle - 1;
}
}
}
std::vector<double> advances;
advances.reserve(fitting - start);
const double width = measure.measure(graphemes, start, fitting, &advances);
Fragment fragment{start < graphemes.size() ? graphemes[start].start : 0,
fitting > start ? graphemes[fitting - 1].end : 0, width, {}};
fragment.graphemes.reserve(fitting - start);
for (size_t i = start; i < fitting; i++) {
auto grapheme = graphemes[i];
grapheme.width = advances[i - start] - (i > start ? advances[i - start - 1] : 0);
fragment.graphemes.push_back(std::move(grapheme));
}
document.fragmentCount++;
document.graphemeCount += fragment.graphemes.size();
document.geometryBytes += sizeof(Fragment) + fragment.graphemes.capacity() * sizeof(Grapheme);
cell.fragments.push_back(std::move(fragment));
start = fitting;
} while (start < graphemes.size());
}
}
std::vector<double> read(const Document& document, size_t start, size_t count) {
std::vector<double> output;
const size_t end = start + std::min(count, document.cells.size() - start);
for (size_t i = start; i < end; i++) {
const auto& cell = document.cells[i];
output.insert(output.end(), {static_cast<double>(i), static_cast<double>(cell.fragments.size())});
for (const auto& fragment : cell.fragments) {
output.insert(output.end(), {static_cast<double>(fragment.start), static_cast<double>(fragment.end), fragment.width, static_cast<double>(fragment.graphemes.size())});
for (const auto& grapheme : fragment.graphemes) {
output.insert(output.end(), {static_cast<double>(grapheme.start), static_cast<double>(grapheme.end), grapheme.width});
}
}
}
return output;
}
} // namespace paseo::diff
@@ -0,0 +1,37 @@
#pragma once
#include <atomic>
#include <cstddef>
#include <string>
#include <vector>
#include "include/core/SkFontMgr.h"
namespace paseo::diff {
struct Grapheme {
int start;
int end;
std::u16string text;
double width = 0;
};
struct Fragment {
int start;
int end;
double width;
std::vector<Grapheme> graphemes;
};
struct Cell {
std::u16string text;
double availableWidth;
std::vector<Fragment> fragments;
};
struct Document {
std::vector<Cell> cells;
size_t fragmentCount = 0;
size_t graphemeCount = 0;
size_t geometryBytes = 0;
};
void prepare(Document& document, sk_sp<SkFontMgr> fonts, const std::string& family, float fontSize,
const std::atomic<bool>& cancelled);
std::vector<double> read(const Document& document, size_t start, size_t count);
} // namespace paseo::diff
@@ -0,0 +1,6 @@
{
"platforms": ["android"],
"android": {
"modules": ["sh.paseo.diffprototype.PaseoDiffPrototypeModule"]
}
}
@@ -0,0 +1,5 @@
{
"name": "paseo-diff-prototype",
"version": "0.1.0",
"private": true
}