Документация Engee
Notebook

Работа с файлами ZIP/XML на примере перевода live-скриптов MATLAB в формат ngscipt

В этом примере мы покажем, как работать с файлами формата OPC (Open Packaging Convensions), то есть с ZIP-контейнерами, содержащими набор разных XML и других файлов. Этот формат встречается повсеместно. Например, форматы такого типа используются во всех приложениях Office (DOCX, XLSX и т.д.) и во многих инженерных пакетах (Autodesk, Simulink, Engee).

Мы преобразуем файл технических расчетов из формата mlx в ngscript – перенесем все текстовые и кодовые ячейки, иллюстрации, гиперссылки и формулы из одного документа в другой.

Введение

Для работы с популярными форматами вроде Office Open XML обычно доступны готовые библиотеки (например, XLSX.jl для электронных таблиц). Но часто нам нужно оперативно обработать формат файла, для которого готовых библиотек еще нет, либо в которых не учитываются нужные вам элементы синтаксиса документа. Представим себя в таком сценарии, когда нам нужно произвести эту обработку вручную. В качестве учебного примера мы разберем программу перекодирования из формата LiveScript пакета MATLAB в формат ngscript.

Для сравнительно низкоуровневой работы с форматами этих файлов нам потребуются следующие библиотеки:

In [ ]:
using EzXML, ZipFile, JSON, Base64

Если какая-нибудь из них еще не установлена, запустите следующую ячейку, предварительно убрав символ # (раскомментировав строку).

In [ ]:
#]add EzXML ZipFile JSON Base64

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

Из чего состоит файл MLX

Формат файла Live Code использует технологию Open Packaging Conventions, которая является расширением формата файла zip. Код и отформатированное содержимое хранятся в XML-документе отличающимся от документа с использованием формата Office Open XML. Чтобы поработать с содержимым этих файлов, достаточно поменять расширение файла на *.zip, а затем разархивировать его через контекстное меню файлового браузера Engee.

image.png

Изучим содержимое файла *.mlx после распаковки. Нам будут нужны следующие файлы из архива:

  • document.xml, где хранится вся текстовая информация документа
  • document.xml.rels – каталог дополнительных материалов, включаемых в документ (формулы, иллюстрации)

image.png

В папке media собраны иллюстрации, которые вставлены в документ, а в папке mathml – использованные формулы в формате MathML.

Загрузка и обработка MLX файлов

Ради упрощения повторного использования нашего кода (а также ради чистоты изложения), организуем его в форме набора функций.

Вот какие функции мы реализуем на этом этапе:

  • получение списка файлов mlx, лежащих в каталоге,
  • распаковка архива и чтение нужных нам файлов,
  • обработка файла ссылок на медиа-файлы,
  • получение списка ячеек из XML файла,
  • перевод ячейки из XML формата в формат JSON.

И одна вспомогательная функция для работы с иллюстрациями, встроенными в файл:

  • получение MIME-информации об иллюстрациях в нужном формате (из названия вроде "image.png" делаем MIME-идентификатор "image/png")

Первым делом, получим список файлов mlx в каталоге.

In [ ]:
function get_list_of_files( base_folder )
    # Сканируем нужный нам каталог (не рекурсивно, без изучения вложенных папок)
    filenames = readdir( base_folder)
    
    # Отфильтруем только файлы с расширением `.mlx`
    list_of_files = [joinpath(base_folder,fname) for fname in filenames if endswith( fname, ".mlx")]
end;

Распакуем mlx файл и сложим интересующее нас содержимое в список.

In [ ]:
function get_mlx_content( mlx_full_filename )
    
    # Откроем архив для чтения содержимого 
    mlx_reader = ZipFile.Reader( mlx_full_filename )
    
    # Прочитаем файлы, которые нас будут интересовать
    document_file = read( [f for f in mlx_reader.files if endswith(f.name, "document.xml")][1], String )
    rels_files_list = [f for f in mlx_reader.files if endswith(f.name, "document.xml.rels")]
    relations_file = length(rels_files_list) > 0 ? read( rels_files_list[1], String ) : nothing;
    
    # В этом списке будут пары название-содержимое
    media_files_list = Dict([ ("../" * f.name, base64encode( read(f, String) )) for f in mlx_reader.files if occursin("media/", f.name ) ])
    
    # Закроем архив
    close( mlx_reader )
    
    return document_file, relations_file, media_files_list
end;

Функция для чтения реестра медиа-материалов (картинок и уравнений).

In [ ]:
function read_rels_file( input_string )

    rels_dict = Dict()
    if !(input_string == nothing) && !(input_string == "")
        rels_tree = EzXML.parsexml( input_string );
        ns = namespace( rels_tree.root )
        for Rel in findall( "w:Relationship", root(rels_tree), ["w"=>ns])
            rels_dict[Rel["Id"]] = Rel["Target"]
        end
    end
    
    return rels_dict
end;

Создание списка ячеек в нужном нам формате.

In [ ]:
function file_to_cells_list( input_string  )
    
    tree = EzXML.parsexml( input_string );
    ns = namespace( tree.root )
    
    # В этом списке будут собраны пара значений для каждой ячейки (параграфа) исходного документа: их стиль и контент
    parsed_mlx = []

    body_node = findfirst( "w:body", root( tree ), ["w"=>ns] );
    for p in findall( "w:p", body_node, ["w"=>ns] )
        
        # Сохраним стиль параграфа отдельно – обычно этот узел встречается один раз внутри каждого параграфа
        pStyle = ""
        pPr_node = findfirst("w:pPr", p, ["w"=>ns])
        if !isnothing( pPr_node )
            # Не будем обрабатывать параграф, если он представляет собой разделитель секций
            if !isnothing( findfirst("w:sectPr", pPr_node, ["w"=>ns]) ) continue; end;
            # Обычный стиль параграфа
            pStyle_node = findfirst("w:pStyle", pPr_node, ["w"=>ns]);
            if !isnothing( pStyle_node ) pStyle = pStyle_node["w:val"]; end;
        end;
        
        # Теперь пройдемся по всем узлам типа run (фрагментам параграфа)
        pContent = []
        element_name = nothing;
        for run in findall("w:*", p, ["w"=>ns])
            #run_name = run.name
            if run.name == "pPr" continue; end;
            runProperty_node = findfirst("w:rPr", run, ["w"=>ns])
            run_content = run.content;
            
            if run.name == "customXml"
                element_name = run["w:element"];
                if element_name == "image"
                    imageNode = findfirst("w:customXmlPr", run, ["w"=>ns])
                    for attr in findall("w:attr", imageNode, ["w"=>ns])
                        if attr["w:name"] == "relationshipId"
                             run_content = attr["w:val"]; end
                    end
                end
            elseif run.name == "hyperlink" # Тип фрагмента w:hyperlink 
                hyperlink_target = "w:docLocation" in attributes(run) ? run["w:docLocation"] : nothing;
                run_content = (run.content, hyperlink_target)
            else
                element_name = nothing;
            end;
            append!( pContent, [(run.name, element_name, runProperty_node, run_content)] );
        end
        
        # Добавим стиль и параграф в список ячеек
        push!( parsed_mlx, (pStyle, pContent) )
    end
    
    return (parsed_mlx, ns)

end;

Создадим функцию, которая возвращает мета-информацию про иллюстрации для ее включения в итоговый документ.

In [ ]:
function process_image_info( image_name )
    
    image_description = ""
    image_name = lowercase(image_name)
    if endswith( image_name, ".png" ) image_description = "image/png"
    elseif endswith( image_name, ".jpg" ) image_description = "image/jpeg"
    elseif endswith( image_name, ".jpeg" ) image_description = "image/jpeg"
    elseif endswith( image_name, ".gif" ) image_description = "image/gif"
    elseif endswith( image_name, ".svg" ) image_description = "image/svg+xml"
    else image_description = "image/unknown"; end;
    
    image_base64_prefix = "data:" * image_description * ";base64,"
    return image_description, image_base64_prefix
end;

Перевод ячейки из формата XML в формат JSON.

In [ ]:
function xml_text_cell_to_plain_text( cell_info, ns, rels_dict, media_files_list )
    
    cell_type, content = cell_info
    attachments = []

    # Иногда стиль ячейки задаст нам начало выводимой строки (в markdown)
    if cell_type == "title" plain_text = "# ";
    elseif cell_type == "heading" plain_text = "## ";
    else plain_text = ""; end;
    
    for (run_name, run_element_type, runProperty_node, run_content) in content
        
        if run_name == "pPr" continue
        elseif run_name == "customXml"
            # Если фрагмент – математическое выражение
            if run_element_type == "equation"
                plain_text = plain_text * "\$" * run_content * "\$"
            # Если фрагмент – иллюстрация
            elseif run_element_type == "image"
                image_name = split( rels_dict[run_content], "/")[end]
                image_content = media_files_list[rels_dict[run_content]]
                image_type, image_prefix = process_image_info( image_name )
                image_base64_content = image_prefix * image_content
                append!( attachments, [(image_name, image_type, image_base64_content)] )
                plain_text = "![$image_name](attachment:$image_name)"
            end
        # Если фрагмент – гиперссылка
        elseif run_name == "hyperlink"
            (hlink_name, hlink_target) = run_content
            if isnothing(hlink_target) plain_text = plain_text * hlink_name;
            else plain_text = plain_text * "[" * hlink_name * "](" * hlink_target * ")"; end;
        # Если фрагмент – кодовое выражение (моноширинный шрифт)
        elseif !isnothing(runProperty_node) && !isnothing(findfirst("w:rFonts", runProperty_node, ["w"=>ns])) && findfirst("w:rFonts", runProperty_node, ["w"=>ns])["w:cs"] == "monospace"
            plain_text = plain_text * "`" * run_content * "`";
        else
            # В ячейке просто текст
            plain_text = plain_text * run_content;
        end
    end
    
    return (cell_type, plain_text, attachments)
end;

Проверочная функция для изучения входных файлов

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

In [ ]:
for mlx_filename in get_list_of_files( "$(@__DIR__)/input" )
    
    (mlx_file, rels_file, media_list) = get_mlx_content( mlx_filename )    
    println(  )
    println( "* Файл ", mlx_filename, " содержит:" )
    
    cell_list, ns = file_to_cells_list( mlx_file )
    println( length([c for c in cell_list if c[1] != "code"]), " текстовых ячеек")
    println( length([c for c in cell_list if c[1] == "code"]), " кодовых ячеек")
    
    rels_dict = read_rels_file( rels_file )
    if length(keys(rels_dict)) > 0
        print( length(keys(rels_dict)), " отсылок к внешним файлам" );
        println( " (из них ", length([trg for (ref,trg) in rels_dict if occursin("../media", trg)]), " на иллюстрации)")
    end;
    
end
* Файл /user/prestart/mlx_to_ngscript_conversion/input/OverviewCreatingAndConcatenatingExample.mlx содержит:
30 текстовых ячеек
19 кодовых ячеек

* Файл /user/prestart/mlx_to_ngscript_conversion/input/nddemo.mlx содержит:
25 текстовых ячеек
13 кодовых ячеек
3 отсылок к внешним файлам (из них 3 на иллюстрации)

Создаем шаблон для итоговых файлов ngscript

Теперь нам нужно подготовить файл .ngscript, в который мы поместим ячейки нашего документа. Формат скриптов Engee обеспечивает обратную совместимость, хотя изменения иногда случаются. Чтобы иметь достаточно свежий шаблон документа ngscript, воспользуемся в качестве шаблона тем же документом, который сейчас открыт перед вами – скриптом mlx_to_ngscript_parser.ngscript.

Загрузим типовый файл ngscript и сделаем из него:

  • шаблон документа,
  • шаблон текстовой ячейки,
  • шаблон кодовой ячейки,

которые мы будем дополнять информацией по мере обработки входных mlx файлов.

In [ ]:
doc_template = JSON.parsefile( "$(@__DIR__)/mlx_to_ngscript_parser.ngscript" );
code_cell_template = [c for c in doc_template["cells"] if c["cell_type"]=="code" ][1];
text_cell_template = [c for c in doc_template["cells"] if c["cell_type"]=="markdown" ][1];
text_cell_template["isParagraph"] = false; # Правка для создания "обычного" параграфа, а не заголовка
doc_template["cells"] = [];

Посмотрим, какие шаблоны мы получили:

In [ ]:
doc_template
Out[0]:
Dict{String, Any} with 4 entries:
  "cells"          => Any[]
  "nbformat_minor" => 5
  "metadata"       => Dict{String, Any}("engee"=>Dict{String, Any}(), "language…
  "nbformat"       => 4
In [ ]:
text_cell_template
Out[0]:
Dict{String, Any} with 6 entries:
  "cell_type"   => "markdown"
  "isParagraph" => false
  "source"      => Any["# Работа с файлами файлов ZIP/XML на примере перевода l…
  "id"          => "6e8ad43b"
  "attachments" => Dict{String, Any}()
  "metadata"    => Dict{String, Any}("name"=>"Название секции", "engee"=>Dict{S…
In [ ]:
code_cell_template
Out[0]:
Dict{String, Any} with 6 entries:
  "outputs"         => Any[]
  "cell_type"       => "code"
  "source"          => Any["using EzXML, ZipFile, JSON, Base64"]
  "id"              => "582c09f7"
  "metadata"        => Dict{String, Any}("name"=>"Название секции", "engee"=>Di…
  "execution_count" => 0

Этих трех шаблонов нам будет достаточно, чтобы сгененировать новый .ngscript файл для каждого файла .mlx в каталоге входных файлов.

Наполняем JSON шаблон

В заключительной функции этого скрипта мы сделаем следующие действия:

  • Пройдемся по всем mlx файлам в каталоге
  • Обработаем содержимое каждого файла
  • Для каждого, создадим шаблон скрипта ngscript
  • Поочередно добавим в него текстовые и кодовые ячейки, не забыв про графические вложения

Кроме этого мы создадим всё необходимое для того, чтобы скрипт на языке MATLAB можно было запустить в окружении Engee:

  • Мы добавим вызов нужной библиотеки в начале файла,
  • Для всех ячеек, где вызывается вывод графиков (например, plot и scatter), мы добавим команды сохранения графика в файловое хранилище и вывод его средствами библиотеки Images.jl, чтобы график оказался в отчете.
In [ ]:
for mlx_filename in get_list_of_files( "$(@__DIR__)/input" )
    
    # Узнаем всё, что нам нужно, про очередной изучаемый файл mlx
    (mlx_file, rels_file, media_list) = get_mlx_content( mlx_filename )
    cell_list, ns = file_to_cells_list( mlx_file )
    rels_dict = read_rels_file( rels_file )
    
    # Создадим шаблон под новый документ и добавим новую ячейку с кодом инициализаици
    ngscript_doc = deepcopy(doc_template);
    new_cell = deepcopy( code_cell_template );
    new_cell["source"][1] = "using MATLAB\nmat\"cd('\$(@__DIR__)')\"";
    push!( ngscript_doc["cells"], new_cell ); # Добавим ячейку в документ
    
    for cell in cell_list
        
        cell_type, plain_text, attachments = xml_text_cell_to_plain_text( cell, ns, rels_dict, media_list )
        plot_counter = 0
        
        if cell_type == "code"
            
            # Перенесем MATLAB-код в ячейку и добавим обрамление в виде префикса mat"""..."""
            new_cell = deepcopy( code_cell_template )
            new_cell["source"][1] = "mat\"\"\"\n" * plain_text * "\n\"\"\"";
            
            # Если в ячейке содержится подстрока plot, добавим
            # MATLAB-инструкции сохранение графика
            # и Engee-инструкции для его оторбажения в отчете
            if cell_type == "code" && occursin("plot", plain_text) || occursin("scatter", plain_text)
                new_cell["source"][1] = "mat\"\"\"\n" * plain_text * "\nsaveas(gcf,'plot_$(plot_counter).png')\n\"\"\"\nusing Images; load(\"plot_$(plot_counter).png\")";
                plot_counter = plot_counter + 1;
            end;
            
            push!( ngscript_doc["cells"], new_cell );
            
        else

            # Добавим текстовую ячейку
            new_cell = deepcopy( text_cell_template )
            new_cell["source"][1] = plain_text
            # Добавим в ячейку вложения (attachments)
            if length(attachments) != 0
                for (image_name, image_type, image_base64_content) in attachments
                    new_cell["attachments"][image_name] = Dict()
                    new_cell["attachments"][image_name][image_type] = image_base64_content
                end
            end
            push!( ngscript_doc["cells"], new_cell )
        
        end
    
    end
    
    # Сохраним скрипт под новым названием
    new_script_name = replace( mlx_filename, ".mlx" => ".ngscript" )
    new_script_name = replace( new_script_name, "input" => "output")
    
    # Предварительно выполним сериализацию и сохраним ngscript
    stringdata = JSON.json( ngscript_doc )
    open(new_script_name, "w") do f write(f, stringdata); end;
    
end

Заключение

В этой демонстрации мы преобразовали файл из формата MLX/OPC (на основе ZIP/XML) в формат ngscript (на основе JSON). Получился инструмент, преобразующий часть информации из набора документов LiveScript среды MATLAB в документы .ngscript платформы Engee.

Из отдельных функий этого примера можно почерпнуть представления об открытии файлов, работе с данными в формате XML, формировании документов на основе JSON. А еще этот пример демонстрирует сильные возможности Engee по части организации технических вычислений, доступных в связке с инструментарием модельно-ориентированного проектирования.