Несколько месяцев назад я писал о форке Chrome, превращающем HTML в SVG, а сегодня хочу сделать нечто похожее, заставив его выполнять рендеринг в терминал.

Представляю вашему вниманию веб-браузер Carbonyl!

Отрисовка



Терминал DEC VT100

Возможности отрисовки в терминале довольно ограничены: вам гарантирован лишь рендеринг моноширинных символов в неизменяемой сетке, и на этом всё. Существуют escape-последовательности, позволяющие выполнять действия, например, перемещать курсор, менять цвет текста или отслеживать мышь. Некоторые сохранились с дней физических терминалов наподобие DEC VT100, другие взяты из проекта xterm.

При использовании какого-нибудь популярного эмулятора терминала, мы можем:

  • Перемещать курсор
  • Записывать символы Unicode
  • Задавать фоновый и основной цвет символов
  • Использовать RGB-палитру 6x6x6 или 24-битную RGB-палитру, если COLORTERM имеет значение truecolor

Один из символов Unicode, который мы можем рендерить — это нижний половинный блок U+2584: . Зная, что в общем случае ячейки имеют соотношение сторон 1:2, мы можем рендерить идеально квадратные пиксели, задав в качестве фонового цвета цвет верхнего пикселя, а в качестве основного цвета — цвет нижнего пикселя.

Давайте подключим вывод html2svg к программе на Rust:

fn move_cursor((x, y): (usize, usize)) {
    println!("\x1b[{};{}H", y + 1, x + 1)
}

fn set_foreground((r, g, b): (u8, u8, u8)) {
    println!("\x1b[38;2;{};{};{}m", r, g, b)
}

fn set_background((r, g, b): (u8, u8, u8)) {
    println!("\x1b[48;2;{};{};{}m", r, g, b)
}

fn print_pixels_pair(
    top: (u8, u8, u8),
    bottom: (u8, u8, u8),
    cursor: (usize, usize)
) {
    move_cursor(cursor);
    set_background(top);
    set_foreground(bottom);
    println!("▄");
}

image

Неплохо. Для рендеринга текста нам нужно создать при помощи C++ новое устройство Skia. Назовём его TextCaptureDevice. Мы заставим его вызывать написанную на Rust функцию draw_text. Как и в html2svg, мы должны преобразовывать ID глифов в символы Unicode.

class TextCaptureDevice: public SkClipStackDevice {
  void onDrawGlyphRunList(SkCanvas*,
                          const sktext::GlyphRunList& glyphRunList,
                          const SkPaint&,
                          const SkPaint& paint) override {
    // Получаем позицию текста
    auto position = localToDevice().mapRect(glyphRunList.origin());

    for (auto& glyphRun : glyphRunList) {
      auto runSize = glyphRun.runSize();
      SkAutoSTArray<64, SkUnichar> unichars(runSize);

      // Преобразуем ID глифов в символы Unicode
      SkFontPriv::GlyphsToUnichars(glyphRun.font(),
                                  glyphRun.glyphsIDs().data(),
                                  runSize,
                                  unichars.get());

      // Отрисовываем этот текст в терминале
      draw_text(unichars.data(), runSize, position, paint.getColor());
    }
  }
}

image

Ещё лучше! Однако в центре текст искажён. Наш TextCaptureDevice не учитывает наложение, отрисовка прямоугольника не очищает текст под ним.

image

Добавим код в методы drawRect и drawRRect, чтобы очищать текст, если мы выполняем заливку сплошным цветом:

void drawRRect(const SkRRect& rect, const SkPaint& paint) override {
    drawRect(rect.rect(), paint);
}

void drawRect(const SkRect& rect, const SkPaint& paint) override {
    if (
        paint.getStyle() == SkPaint::Style::kFill_Style &&
        paint.getAlphaf() == 1.0
    ) {
        clear_text(localToDevice().mapRect(rect));
    }
}

image

Серый фон под текстовыми элементами возникает из-за того, что программный растеризатор рендерит текст в наше битовое изображение. Давайте удалим его:

chromium/third_party/skia/src/core/SkBitmapDevice.cpp, строка 521

void SkBitmapDevice::onDrawGlyphRunList(SkCanvas* canvas,
                                        const sktext::GlyphRunList& glyphRunList,
                                        const SkPaint& initialPaint,
                                        const SkPaint& drawingPaint) {
//    SkASSERT(!glyphRunList.hasRSXForm());
//    LOOP_TILER( drawGlyphRunList(canvas, &fGlyphPainter, glyphRunList, drawingPaint), nullptr )
}

image

Это было самое простое. А теперь будем обрабатывать ввод!

Ввод


Некоторые последовательности позволяют эмулятору терминала отслеживать и сообщать о событиях мыши. Например, если ввести \x1b[?1003h, то терминал должен начать отправлять события в следующем формате:

fn report_mouse_move((x, y): (usize, usize)) {
    write!(get_stdin(), "\x1b[<35;{};{}M", y + 1, x + 1)
}
fn report_mouse_down((x, y): (usize, usize)) {
    write!(get_stdin(), "\x1b[<0;{};{}M", y + 1, x + 1)
}
fn report_mouse_up((x, y): (usize, usize)) {
    write!(get_stdin(), "\x1b[<0;{};{}m", y + 1, x + 1)
}

Они схожи с последовательностями, которые мы используем для стилизации вывода. Префикс \x1b[ называется Control Sequence Introducer.

carbonyl::browser->BrowserMainThread()->PostTask(
    FROM_HERE,
    base::BindOnce(
        &HeadlessBrowserImpl::OnMouseDownInput,
        x,
        y
    )
);

Нам нужно уведомлять браузер о необходимости завершения этой задачи, но есть одна трудность: нужно запретить потоку считывать stdin, однако методы браузера должны вызываться из основного потока. К счастью, почти везде возможно пробрасывание сообщений при помощи класса TaskRunner.

carbonyl/src/input/parser.rs, строка 69

for &key in input {
    sequence = match sequence {
        Sequence::Char => match key {
            0x1b => Sequence::Escape,
            0x03 => emit!(Event::Exit),
            key => emit!(Event::KeyPress { key }),
        },
        Sequence::Escape => match key {
            b'[' => Sequence::Control,
            b'P' => Sequence::DeviceControl(DeviceControl::new()),
            0x1b =>
                emit!(Event::KeyPress { key: 0x1b }; continue),
            key => {
                emit!(Event::KeyPress { key: 0x1b });
                emit!(Event::KeyPress { key })
            }
        },
        Sequence::Control => match key {
            b'<' => Sequence::Mouse(Mouse::new()),
            b'A' => emit!(Event::KeyPress { key: 0x26 }),
            b'B' => emit!(Event::KeyPress { key: 0x28 }),
            b'C' => emit!(Event::KeyPress { key: 0x27 }),
            b'D' => emit!(Event::KeyPress { key: 0x25 }),
            _ => Sequence::Char,
        },
        Sequence::Mouse(ref mut mouse) => parse!(mouse, key),
        Sequence::DeviceControl(ref mut dcs) => parse!(dcs, key),
    }
}

Google рекомендует мне установить Chrome

Pipe


Наш проект более-менее работает, но ценой стабильного использования CPU на 400%, и это не считая iTerm2, который использует примерно 200%. У нас несколько проблем:

  • Для рендеринга в 5 FPS требуется слишком много ресурсов
  • Мы выполняем рендеринг в каждом кадре, даже если ничего не меняется
  • Мы выводим все символы, даже если они не меняются на индивидуальном уровне

Для повышения безопасности в современных браузерах используется многопроцессная архитектура. Она разделяет веб-сайты на отдельные процессы, снижая потенциальный ущерб, вызываемый уязвимостями. Процесс рендерера работает в среде песочницы на уровне ОС, блокирующей определённые системные вызовы, например, доступ к файловой системе. Считается, что процесс GPU тоже не имеет привилегий и он не может получить доступ к процессу рендерера; это сделано для защиты от уязвимостей в GPU API наподобие WebGL. Процесс браузера, напротив, считается привилегированным, поэтому свободно может обраться с любым процессом.


CapturePaintPreview прекрасно подходит для html2svg, однако не предназначен для рендеринга в реальном времени. Для корректной поддержки iframe вне процесса он использует вызовы IPC, совершая маршрут между процессами браузера, GPU и рендерера. Он скачивает аппаратно ускоренные изображения из GPU, что объясняет удивительную нагрузку на полосу пропускания памяти. Мы можем отключить передачу и даже отключить аппаратное ускорение, однако нас всё равно будут сдерживать затратные механизмы IPC.

Программный рендеринг по-прежнему активно используется; можете не верить, но раньше он даже применялся по умолчанию. Во времена однопроцессности работать с ним было довольно просто, однако сегодня общие области памяти сконфигурированы под эффективный рендеринг при помощи множества процессов. Если мы сможем засунуть наши пиксели в одну из этих областей памяти, нам достаточно будет просто уведомлять процесс браузера при помощи простого сообщения IPC.


Чтобы подготовить эту общую память, нужно реализовать HostDisplayClient и SoftwareOutputDevice для управления собственным LayeredWindowUpdater, реализующим OnAllocatedSharedMemory().

HostDisplayClient выполняется в процессе браузера и вызывается процессом GPU через IPC. Чтобы закончить со всем этим, нам нужно заставить процесс GPU использовать наш собственный клиент дисплея, добавив в VizProcessTransportFactory::OnEstablishedGpuChannel() следующее:

chromium/content/browser/compositor/viz_process_transport_factory.cc, строка 402

compositor_data.display_client =
//      std::make_unique<HostDisplayClient>(compositor);
      std::make_unique<carbonyl::HostDisplayClient>();

carbonyl/src/browser/host_display_client.cc, строка 28

void LayeredWindowUpdater::OnAllocatedSharedMemory(
    const gfx::Size& pixel_size,
    base::UnsafeSharedMemoryRegion region
) {
    if (region.IsValid())
        shm_mapping_ = region.Map();
}

void LayeredWindowUpdater::Draw(
    const gfx::Rect& damage_rect,
    DrawCallback draw_callback
) {
    carbonyl_draw_bitmap(
        shm_mapping_.GetMemoryAs<uint8_t>(),
        shm_mapping_.size()
    );

    std::move(draw_callback).Run();
}

Мы решили проблему с битовыми картами, но как нам извлекать текстовые данные? Эти данные находятся в процессе рендерера, однако наш код находится в процессе браузера. Нам нужно сделать так, чтобы рендерер взаимодействовал с процессом браузера.

Mojo


Mojo — это библиотека для обмена данными между процессами. Она определяет IDL для сериализации данных, поддерживающий нативные идентификаторы (например, дескрипторы файлов, общие области памяти, обратные вызовы), и может использоваться для генерации привязок C++, Java (Android) и JavaScript (DevTools). Она имеет подробную документацию и довольно проста в работе.

Мы начнём с создания интерфейса CarbonylRenderService, работающего в процессе браузера, с методом DrawText, вызываемым из процесса рендерера.

carbonyl/src/browser/carbonyl.mojom

// Наши привязки C++ будут находиться в пространстве имён carbonyl::mojom
module carbonyl.mojom;

// Импортируем имеющиеся привязки в стандартные структуры
import "ui/gfx/geometry/mojom/geometry.mojom";
import "skia/public/mojom/skcolor.mojom";

// Определяем структуру, в которой будет храниться текст для рендеринга
struct TextData {
    // Строка UTF-8 с содержимым
    string contents;
    // Границы, размер определяется только для очистки
    gfx.mojom.RectF bounds;
    // Цвет текста
    skia.mojom.SkColor color;
};

// Этот сервис выполняется процессом браузера
interface CarbonylRenderService {
    // Этот метод вызывается процессом рендерера
    DrawText(array<TextData> data);
};

Этот код .mojom генерирует временные файлы C++, которые мы можем добавить для написания кода реализации.

Получатели Mojo, например, наш сервис, являются частью нативных идентификаторов, которые мы можем передавать между процессами; для регистрации реализации достаточно добавить её в BrowserInterfaceBroker, который будет вызываться рендерером через BrowserInterfaceBrokerProxy:

chromium/content/browser/browser_interface_binders.cc, строка 890

map->Add<carbonyl::mojom::CarbonylRenderService>(
    base::BindRepeating(&RenderFrameHostImpl::GetCarbonylRenderService,
                        base::Unretained(host)));

chromium/content/renderer/render_frame_impl.cc, строка 2206

GetBrowserInterfaceBroker().GetInterface(
  std::move(carbonyl_render_service_receiver_)
);

Теперь нам нужно получать текстовые данные без прохождения затратного маршрута. У Blink есть метод GetPaintRecord(), получающий последние данные отрисовки для страницы, но он находится не за публичным API, а это нам нужно, поскольку наш код работает в рендерере контента. В идеале нам бы хотелось подключить его к компоновщику (cc), но это гораздо сложнее. Быстрое и грязное решение заключается в том, чтобы привести это к приватному blink::WebViewImpl:

auto* view = static_cast<blink::WebViewImpl*>(GetWebFrame()->View());

view->MainFrameImpl()->GetFrame()->View()->GetPaintRecord().Playback(&canvas);
carbonyl_render_service_->DrawText(std::move(data));

Сюрприз после первого запуска: текстовое содержимое не следует за битовой картой. А, понятно: скроллинг и анимирование выполняются в потоке компоновщика, что освобождает основной поток и упрощает всё. Давайте будем прокрастинировать правильно и добавим к аргументам командной строки --disable-threaded-scrolling --disable-threaded-animation.

Потоковый композитинг включен

Потоковый композитинг отключен

Всё довольно плавно, и будет ещё плавнее, когда мы починим потоковый композитинг! И мы решили нашу самую большую проблему: при простое мы не тратим ресурсы CPU, а скроллинг потребляет примерно 15%.

Структура страниц


Проблема в том, что мы можем выполнять рендеринг только одним размером шрифта, но Blink этого не знает. Из-за этого структура страницы может искажаться, а некоторые текстовые блоки могут накладываться друг на друга или иметь слишком большие расстояния. Особенно заметно это на веб-сайтах с большим количеством текстового содержимого и ссылок, например, в Википедии.

Ещё один грязный (но эффективный) трюк — принудительно использовать для каждого элемента моноширинный шрифт. Это можно сделать, добавив код в StyleResolver::ResolveStyle.

chromium/third_party/blink/renderer/core/css/resolver/style_resolver.cc, строка 954

auto font = state.StyleBuilder().GetFontDescription();

font.SetStretch(ExtraExpandedWidthValue());
font.SetKerning(FontDescription::kNoneKerning);
font.SetComputedSize(11.75 / 7.0);
font.SetGenericFamily(FontDescription::kMonospaceFamily);
font.SetIsAbsoluteSize(true);
state.StyleBuilder().SetFontDescription(font);
state.StyleBuilder().SetLineHeight(Length::Fixed(14.0 / 7.0));


Стандартная структура страницы


Исправленная структура

LoDPI


Затратным этапом в нашем конвейере рендеринга является даунскейлинг: нам нужно изменить размер буфера кадров с виртуального пространства на физическое. То, что мы делаем, противоположно рендерингу HiDPI, наиболее популярное соотношение сторон которого 2, то есть 1 пиксель в вебе будет равен 4 пикселям на экране. Наше соотношение равно 1 / 7, то есть 49 пикселей в вебе рендерится в 1 блок терминала.

HiDPI раздражает тем, что может сделать рендеринг примерно в 4 раза медленнее, в то время как Carbonyl LoDPI® ускоряет рендеринг примерно в 49 раз. Нам только нужно принудительно засунуть масштабирование в класс Display.

chromium/ui/display/display.cc, строка 77

// static
float Display::GetForcedDeviceScaleFactor() {
    return 1.0 / 7.0;
}

// static
bool Display::HasForceDeviceScaleFactor() {
    return true;
}

Цвет


Я поискал примеры преобразования цветов RGB в xterm-256, но найденный мной код был ошибочным или медленным, потому что выполнял поиск ближайших соседей. Мы будем выполнять его для каждого пикселя, так что он должен быть быстрым.

Формула преобразования достаточно проста (если значения цветов находятся в интервале от 0 и 1): 16 + r * 5 * 36 + g * 5 * 6 + b * 5.

Ошибка большинства найденного мной кода заключалась в следующей особенности: шесть уровней цвета нелинейны: 0, 95, 135, 175, 215, 255; между первым и вторым значением составляет 95, а между остальными 40.

Логично ограничить интервал тёмных оттенков, ведь различия цветов заметнее на светлых цветах. Это значит, что мы можем преобразовать значение от 0 до 255 при помощи max(0, color - 95 - 40) / 40.

pub fn to_xterm(&self) -> u8 {
    let r = (self.r as f32 - (95.0 - 40.0)).max(0.0) / 40.0;
    let g = (self.g as f32 - (95.0 - 40.0)).max(0.0) / 40.0;
    let b = (self.b as f32 - (95.0 - 40.0)).max(0.0) / 40.0;

    (16.0 +
        r.round() * 36.0 +
        g.round() * 6.0 +
        b.round()) as u8
}

Само преобразование можно считать скалярным произведением (r, g, b) и (36, 6, 1). Мы можем перенести вычитание в вызов mul_add, чтобы помочь компилятору использовать команду FMA.

Последний этап — это оттенки серого: наш профиль xterm предоставляет 256 цветов, в нём 216 цветов из куба RGB (6 * 6 * 6), 16 настраиваемых системных цветов и 24 уровней серого от rgb(8,8,8) до rgb(238,238,238).

Чтобы определить, находится ли цвет в оттенках серого, мы можем вычесть его минимальное значение до его максимального значения и проверить, находится ли он ниже порогового значения, скажем, 8.

carbonyl/src/output/xterm.rs, строка 4

pub fn to_xterm(&self) -> u8 {
    if self.max_val() - self.min_val() < 8 {
        match self.r {
            0..=4 => 16,
            5..=8 => 232,
            238..=246 => 255,
            247..=255 => 231,
            r => 232 + (r - 8) / 10,
        }
    } else {
        let scale = 5.0 / 200.0;

        (16.0
            + self
                .cast::<f32>()
                .mul_add(scale, scale * -55.0)
                .max(0.0)
                .round()
                .dot((36.0, 6.0, 1.0))) as u8
    }
}


Простой куб RGB


С путём кода оттенков серого

Но у нас всё равно остаётся одна небольшая проблема: как определить, поддерживает ли терминал true color или 256 цветов? Загуглив, мы находим переменную окружения COLORTERM, которая в случае поддержки true color имеет значение 24bit или truecolor. Но она не сработает в Docker или SSH, которые являются нашими основными платформами.

Мы можем использовать трюк с DCS (Device Control Sequence) для получения значения параметра, например, текущего фонового цвета. Если мы зададим значение RGB и получим значение RGB, значит, можно включить true color.

Это можно проверить, запустив в терминале следующее:

$ printf "\e[48;2;13;37;42m\eP\$qm\e\\"; cat

  • \e: начинаем escape-последовательность
    • [: начинаем последовательность управления
    • 48: задаём фон
    • 2: используем цвет RGB
    • 13: R = 13
    • 37: G = 37
    • 42: B = 42
    • m: выбираем графический рендеринг

  • \e: начинаем escape-последовательность
    • P: начинаем device control sequence
    • $: входим в режим состояния
    • q: запрашиваем текущий параметр
    • m: выбираем графический рендеринг

Если команды поддерживаются, вы должны получить следующий результат с тёмно-бирюзовым цветом:

^[P1$r0;48:2:1:13:37:42m^[\

Именно это эмулятор терминала отправляет в stdin, и это мы можем спарсить, чтобы включить true color.

carbonyl/src/input/dcs/parser.rs, строка 27

self.sequence = match self.sequence {
    // ^[P1$r0;48:2:1:13:37:42m^[\
    Code => match key {
        b'0' | b'1' => Type(key),
        _ => control_flow!(break)?,
    },
    // ^[P1$r0;48:2:1:13:37:42m^[\
    Type(code) => match key {
        b'$' => Status(StatusParser::new(code)),
        b'+' => Resource(ResourceParser::new(code)),
        _ => control_flow!(break)?,
    },
    // ^[P1$r0;48:2:1:13:37:42m^[\
    Status(ref mut status) => return status.parse(key),
    Resource(ref mut resource) => return resource.parse(key),
};

carbonyl/src/input/dcs/status.rs, строка 33

self.sequence = match self.sequence {
    // ^[P1$r0;48:2:1:13:37:42m^[\
    Start => match key {
        b'r' => Value,
        _ => control_flow!(break)?,
    },
    // ^[P1$r0;48:2:1:13:37:42m^[\
    Value => match key {
        // ^[P1$r0;48:2:1:13:37:42m^[\
        0x1b => self.terminate(),
        // ^[P1$r0;48:2:1:13:37:42m^[\
        b';' => self.push_value(),
        // ^[P1$r0;48:2:1:13:37:42m^[\
        char => self.push_char(char),
    },
    // ^[P1$r0;48:2:1:13:37:42m^[\
    Terminator => control_flow!(break self.parse_event(key))?,
};

Заголовок


Последовательности xterm позволяют задавать заголовок окна терминала, мы можем использовать их для отображения заголовка текущей страницы.

fn set_title(title: &str) {
    // Задаём имени значка и заголовку окна значение string
    println!("\x1b]0;{}\x07", title);
    // Задаём имени значка значение string
    println!("\x1b]1;{}\x07", title);
    // Задаём заголовку окна значение string
    println!("\x1b]2;{}\x07", title);
}

Чтобы получать уведомления об изменении заголовка, можно просто реализовать WebContentsObserver::TitleWasSet():

void HeadlessWebContentsImpl::TitleWasSet(content::NavigationEntry* entry) {
    carbonyl::Renderer::Main()->SetTitle(
        base::UTF16ToUTF8(entry->GetTitleForDisplay())
    );
}

Отлично

Комментарии (12)


  1. MichaelSkirda
    00.00.0000 00:00
    +2

    Помню делал похожую, но очень примитивную программу. Она использовала Puppeteer, делала скриншот с headless браузера и рисовала его в терминале.


  1. rsashka
    00.00.0000 00:00
    +2

    Афигительно!




  1. AlexanderAstafiev
    00.00.0000 00:00

    А ведь если к этому еще и sixel прикрутить...


  1. IgorPie
    00.00.0000 00:00

    Lynx - жив!


    1. kovserg
      00.00.0000 00:00

      links2


      1. asked2return
        00.00.0000 00:00

        RIPscrip vector graphic


    1. asked2return
      00.00.0000 00:00

      bbs


  1. butsan
    00.00.0000 00:00
    +2

    Работает и в эмуляции Linux под Windows 11 ????круто, спасибо!


  1. Maxim_Q
    00.00.0000 00:00

    Как проходит решение капчи в браузере? В теории только капча от Google третьей версии где не требуется участие человека может пройти удачно, но это не точно. Если есть опыт поделитесь.


  1. xkafkax
    00.00.0000 00:00

    VIM биндинги?
    сильно лучше чем browsh?