Pull to refresh

Preview документов в программе на Python

Reading time11 min
Views17K
В одной из систем, к которым я имею отношение, doc-файлы складываются в базу данных.
Мне стало интересно, можно ли пристроить в свою программку, работающую с базой, просмотр этих файлов.


Почему-то естественным решением подобных задач обычно считают запуск MSWord с именем файла в командной строке. Но этот способ, мягко говоря, не слишком безопасен — в doc-е могут быть макросы, или это может быть вообще не doc, а специально приготовленный взломщиком файл. Поэтому лучше использовать специальный объект просмотра, реализованный в Офисе. Он более защищен, так как ничего другого, кроме просмотра документа, делать не умеет.

А если мы не будем ограничиваться одним форматом doc, тогда в качестве бонуса получим возможность просмотра вложенных документов в других форматах, для которых в Windows зарегистрированы стандартные просмотрщики.

Забегая вперед — всё получилось с помощью PyWin32. Правда, неожиданно в процессе пришлось скомпилировать свой пакет для поддержки нужного COM-интерфейса, но обошлось без жертв.

Итак, что мы знаем.

  1. Согласно MSDN, в системе есть просмотрщики, реализующие стандартный интерфейс IPreviewHandler, интерфейс описан в инклюднике Shobjidl.h
  2. Можно проверить, есть ли зарегистрированный просмотрщик в системе для конкретного расширения файла — если существует ветка HKEY_CLASSES_ROOT\<extn>\Shellex\{8895b1c6-b41f-4c1c-a562-0d564250836f} (где "<extn>" — это расширение файла с точкой, т.е. ".doc", ".pdf", и так далее), и в ней есть дефолтное значение, то это значение — CLSID соответствующего компонента.
  3. Все зарегистрированные просмотрщики перечислены в ветке реестра HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\Windows\CurrentVersion\PreviewHandlers

Порядок работы:

4. Есть документ на диске.

5. По расширению файла находим CLSID, по нему создаем объект просмотрщика.

6. Объект инициализируем нашим файлом — либо по имени, либо потоком IStream — об этом позже.

7. Указываем объекту окно, в котором он должен отображаться, вызывая метод SetWindow — здесь нам нужен будет хэндл окна, но проблем нет, у виджетов Qt для этого есть метод winId().

8. Для запуска просмотра вызвать у объекта метод DoPrevew.

9. Если окно меняет размеры, то нужно вызывать SetRect для соответствующего изменения размера просмотра.

10. Когда просмотрщик больше не нужен — вызываем у него Unload

Надо выяснить, как проще всего в Python (я еще не сказал, что у меня программа на питоне?) создать компонент по его CLSID.

На Stack Overflow советуют для таких вещей ставить PyWin32. Ок, попробуем.

C:\>pip3 install pywin32
Collecting pywin32
  Could not find a version that satisfies the requirement pywin32 (from versions: )
No matching distribution found for pywin32

Что за…? В смысле «не могу найти версию»?
Снова гуглю — ага, инсталлировать надо «pypiwin32», ибо, как сказано:
Pypiwin32 is a repackaging of pywin32 to use sane packaging tools (namely
wheels). Its repackaged by the BDFL of the Twisted project. If you use
pip, or virtualenvs (and you should be using pip and virtualenvs, if you are
not, start), use pypiwin32.

C:\>pip3 install pypiwin32

Уф, поставилось. Большое дело сделали!

Теперь надо проверить, пишу небольшой скрипт:

#!/usr/bin/python3
# -*- coding: utf-8 -*-

import pythoncom
import pywintypes

adobe = pywintypes.IID('{DC6EFB56-9CFA-464D-8880-44885D7DC193}')
CLSID_IPreviewHandler = '{8895B1C6-B41F-4C1C-A562-0D564250836F}'
iid = pywintypes.IID(CLSID_IPreviewHandler)

handler = pythoncom.CoCreateInstance(
    adobe, None,
    pythoncom.CLSCTX_LOCAL_SERVER,
    iid)
print(handler)

Здесь создается один из просмотрщиков, имеющихся в системе, конкретно для Adobe pdf. Просто создается, без дальнейших действий. Если заработает, то потом можно подергать его методы.

Запускаю и получаю ту самую неожиданность

Traceback (most recent call last):
  File "C:\Projects\pytest\w1.py", line 19, in <module>
    iid)
TypeError: There is no interface object registered that supports this IID

То есть создать-то просмотрщик он создал, но вернуть не смог — нет, видите ли, у него зарегистрированного интерфейсного объекта, который поддерживает энтот IID.

В чем-то я с ним согласен — питону нужно знать, какие у созданного COM-объекта есть методы, чтобы позволить их вызывать из питоновского скрипта. Такую информацию предоставляет интерфейс IDispatch, но в данном объекте его нет…

Так что же делать? Погуглив по тексту сообщения об ошибке, нахожу ответ разработчика пакета Mark Hammond:
>The document PythonCOM.html says that this is done using a «pyd» module that
>is imported. Does this mean that for every interface that is accessed in
>this manner a C or C++ module must be created specifically for that
>interface?


Exactly. Note however that many useful objects use the «IDispatch»
interface, but for custom objects that dont, this is true.

> If this is needed, is there somewhere that I can see an example
>of the code for that module? If not, how do I tell Python about the
>interface object associated with the IID?


There are a number of examples in the win32com sources. The most
recent set are in the «internet» and «axcontrols» directory.

Also note that there are 2 options for generating the C code. One
is to use «makegw» that comes with win32com — it takes a .h file
that has geen itself generated from an IDL file, and create C source
code. But its not very flexible. There is also SWIG, which is far
more flexible, but probably a much higher learning curve to set up.
If the interfaces are small, and in a .H file generated from an IDL,
then check out «makegw» and the samples I mentioned (which
themselves where generated with makepy)

Короче, предлагает покодить на старом, добром C. Инклюдники, компилятор, линкер — вот это всё, которое, уходя на Python, я хотел избежать. А примеры предлагает взять из исходников пакета. Исходники я скачал, потом пригодились.

И два варианта сборки

  • «makegw», входящий в пакет
  • SWIG, который круче.

Про SWIG нашлась статья на хабре "Python, Модули, SWIG, Windows" mclander, где вроде всё хорошо, легко и здорово. Скачал я этот SWIG, попробовал с ним разобраться — с налета не вышло, а с makegw получилось.

makegw — это модуль с фактически одной функцией, которую нужно запустить с нужными параметрами — путь к исходном инклюднику, в данном случае ShObjIdl.h из Windows SDK, и нужный интерфейс, поэтому я написал скриптик.

mk.py


import win32com.makegw.makegw

inc = "C:/Program Files (x86)/Windows Kits/10/Include/10.0.14393.0/um/"
h = inc + "ShObjIdl.h"

win32com.makegw.makegw.make_framework_support(h, "IPreviewHandler")

Скрипт отработал, получились два файла PyIPreviewHandler.cpp и PyIPreviewHandler.h. Заглянув в сишник, вижу такую картину:

// *** The input argument hwnd of type "__RPC__in HWND" was not processed ***
//     Please check the conversion function is appropriate and exists!
	__RPC__in HWND hwnd;
	PyObject *obhwnd;
	// @pyparm <o Py__RPC__in HWND>|hwnd||Description for hwnd


// *** The input argument prc of type "__RPC__in const RECT *" was not processed ***
//     Please check the conversion function is appropriate and exists!
	__RPC__in const RECT prc;
	PyObject *obprc;
	// @pyparm <o Py__RPC__in const RECT>|prc||Description for prc

То есть makegw не смог, да и не пытался разобраться, что означают конструкции "__RPC__in HWND", "__RPC__in const RECT *" и так далее. О чем и предупредил.

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

Взял ShObjIdl.h, выдрал из него описание интерфейса IPreviewHandler в отдельный файл, поменял типы у параметров.

preview.h
#include "rpc.h"
#include "rpcndr.h"
#include "windows.h"
#include "ole2.h"

//#define __RPC__in

#ifndef __IPreviewHandler_INTERFACE_DEFINED__
#define __IPreviewHandler_INTERFACE_DEFINED__

/* interface IPreviewHandler */
/* [uuid][object] */ 

#include "prtypes.h"

EXTERN_C const IID IID_IPreviewHandler;

    MIDL_INTERFACE("8895b1c6-b41f-4c1c-a562-0d564250836f")
    IPreviewHandler : public IUnknown
    {
    public:
        virtual HRESULT STDMETHODCALLTYPE SetWindow( 
            /* [in] */ HWND hwnd,
            /* [in] */ CRECTPTR prc) = 0;
        
        virtual HRESULT STDMETHODCALLTYPE SetRect( 
            /* [in] */ CRECTPTR prc) = 0;
        
        virtual HRESULT STDMETHODCALLTYPE DoPreview( void) = 0;
        
        virtual HRESULT STDMETHODCALLTYPE Unload( void) = 0;
        
        virtual HRESULT STDMETHODCALLTYPE SetFocus( void) = 0;
        
        virtual HRESULT STDMETHODCALLTYPE QueryFocus( 
            /* [out] */ HWNDPTR phwnd) = 0;
        
        virtual HRESULT STDMETHODCALLTYPE TranslateAccelerator( 
            /* [in] */ MSGPTR pmsg) = 0;
        
    };
    
#endif


Новые типы описал в отдельном файле

prtypes.h
typedef const RECT *CRECTPTR;
typedef const MSG *CMSGPTR;
typedef MSG *MSGPTR;
typedef HWND *HWNDPTR;

Соответственно, в скрипте поменял имя инклюдника. Заодно отключил генерацию объекта-шлюза — дело в том, что я собираюсь получать реализованный интерфейс из внешней библиотеки, а не создавать его в питоне, поэтому шлюз, отвечающий за генерацию мне не нужен.

mk.py
import win32com.makegw.makegw

win32com.makegw.makegw.make_framework_support("preview.h", "IPreviewHandler", bMakeGateway = 0)

Запустил

C:\Projects\pytest>python mk.py
IPreviewHandler

Так, теперь собрать надо пакет. Покурив документацию на Python, выясняю (здесь и здесь), что для сборки необходимо и достаточно сделать скрипт setup.py. Вы-то, наверное и так знали, а у меня это первый раз, в смысле сборка пакета. Делаем, чё.

#!/usr/bin/env python

from distutils.core import setup, Extension

pypacks = "C:/Python/Lib/site-packages/"
wdkinc = "C:\\Program Files (x86)\\Windows Kits\\10\\Include\\10.0.14393.0\\"
wdklib = "C:\\Program Files (x86)\\Windows Kits\\10\\Lib\\10.0.14393.0\\"
pywinsrc = "C:/Projects/Source/pywin32-221/"

example_module = Extension('_preview',
                           sources=['PyIPreviewHandler.cpp','prtypes.cpp'],
                           include_dirs=[wdkinc + "ucrt", 
                                         pywinsrc + "com/win32comext/shell/src",
                                         pypacks + "win32/include",
                                         pypacks + "win32com/include"],
                           library_dirs=[wdklib + "ucrt\\x86",
                                         pypacks + "win32/libs",
                                         pypacks + "win32com/libs"]
                           )

setup (name = 'preview',
       version = '0.1',
       author      = "My",
       description = """Simple swig example from docs""",
       ext_modules = [example_module],
       py_modules = ["preview"],
       )

У меня уже стояли на машине Windows SDK (точнее, WDK, но не принципиально) и Visual Studio Community 2017, было интересно, найдет ли их setup.py. Компилятор сам нашел, а пути к SDK пришлось указывать.

C:\Projects\pytest>python.exe setup.py build_ext --inplace >err.txt
error: command 'D:\\Program Files (x86)\\Microsoft Visual Studio 14.0\\VC\\BIN\\cl.exe' failed with exit status 2

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

PyIPreviewHandler.cpp(46): error C3861: 'PyObject_AsCRECTPTR': identifier not found


Раньше я определил новые типы данных, а теперь нужны функции преобразования данных этих типов из объектов питона в С и наоборот. Поискав среди исходников PyWin32, нашел внутри функции PyObject_AsRECT, PyObject_FromRECT и так далее — словом, все что нужно. Пришлось поправить сгенеренный сишник, чтобы использовать эти функции.

Было:

	CRECTPTR prc;
	PyObject *obprc;
        ...
	if (bPythonIsHappy && !PyObject_AsCRECTPTR( obprc, &prc )) bPythonIsHappy = FALSE;
        ...
	PyObject_FreeCRECTPTR(prc);

Стало:

	RECT prc;
	PyObject *obprc;
        ...
	if (bPythonIsHappy && !PyObject_AsRECT( obprc, &prc )) bPythonIsHappy = FALSE;
        ...
	//PyObject_FreeCRECTPTR(prc);

И так далее, благо методов у IPreviewHandler не так много. Однако функции преобразования пришлось вытащить из исходников и вставить в свой файл prtypes.cpp, потому что они в PyWin32 не вынесены в библиотеку.

prtypes.cpp
#include "shell_pch.h"
#include "prtypes.h"

BOOL PyObject_AsMSG( PyObject *obpmsg, MSG *msg )
{
	PyObject *obhwnd;
	return PyArg_ParseTuple(obpmsg, "Oiiii(ii)", &obhwnd,&msg->message,&msg->wParam,&msg->lParam,&msg->time,&msg->pt.x,&msg->pt.y)
		&& PyWinObject_AsHANDLE(obhwnd, (HANDLE *)&msg->hwnd);
}

PyObject *PyObject_FromMSG(const MSG *msg)
{
	if (!msg) {
		Py_INCREF(Py_None);
		return Py_None;
	}
	return Py_BuildValue("Niiii(ii)", PyWinLong_FromHANDLE(msg->hwnd),msg->message,msg->wParam,msg->lParam,msg->time,msg->pt.x,msg->pt.y);
}

BOOL PyObject_AsRECT( PyObject *ob, RECT *r)
{
	return PyArg_ParseTuple(ob, "iiii", &r->left, &r->top, &r->right, &r->bottom);
}
PyObject *PyObject_FromRECT(const RECT *r)
{
	if (!r) {
		Py_INCREF(Py_None);
		return Py_None;
	}
	return Py_BuildValue("iiii", r->left, r->top, r->right, r->bottom);
}


Зато теперь компилируется без обращения к исходникам. Компилируется, но не собирается.

LINK : error LNK2001: unresolved external symbol PyInit__preview
build\temp.win32-3.6\Release\_preview.cp36-win32.lib : fatal error LNK1120: 1 unresolved externals

Такое чувство, что тебе что-то недорассказали. PyInit_xxx похоже на стандартное имя для инициализации модуля, вопрос только, что в нем должно быть, как зарегистрировать интерфейс. Пришлось снова распутывать исходники PyWin32 и выяснять, что нужно для полной сборки. По аналогии с найденными функциями PyInit_xxx добавил свою.

#include "PythonCOMRegister.h" // For simpler registration of IIDs 
...
// Методы пакета не определяем
static struct PyMethodDef preview_methods[] = {{NULL}};

PyObject *PyInit__preview(void)
{
	static PyModuleDef _preview_def = {
		PyModuleDef_HEAD_INIT,
		"_previewer",
		"Preview Handler Interface",
		-1,
		preview_methods
		};
	PyObject *module=PyModule_Create(&_preview_def);
        // Регистрируем интерфейс
        PyCom_RegisterClientType(&PyIPreviewHandler::type, &IID_IPreviewHandler);
	return module;
}

Теперь собрался файл _preview.cp36-win32.pyd (и тут Штирлиц догадался, что подчеркивание было лишним). Инсталлируем получившийся пакет.

C:\Projects\pytest>python.exe setup.py install

Проверяю — в том же тестовом скрипте после импортов лишь добавляю import _preview

весь скрипт
<source lang="python">#!/usr/bin/python3
# -*- coding: utf-8 -*-

import pythoncom
import pywintypes
import _preview

adobe = pywintypes.IID('{DC6EFB56-9CFA-464D-8880-44885D7DC193}')
CLSID_IPreviewHandler = '{8895B1C6-B41F-4C1C-A562-0D564250836F}'
iid = pywintypes.IID(CLSID_IPreviewHandler)

handler = pythoncom.CoCreateInstance(
    adobe, None,
    pythoncom.CLSCTX_LOCAL_SERVER,
    iid)
print(handler)


Запускаю и получаю:

C:\Projects\Python\test>python wincom.py
<PyIPreviewHandler at 0x00817770 with obj at 0x00745FFC>

Однако, работает, объект создался.

Осталось использовать изделие по назначению. Чтобы проверить на разных форматах документов, я написал скрипт с использованием QFileSystemModel и QTreeView из PyQt5, т.е. слева у меня будет дерево файловой системы, а справа — preview выбранного файла.



Скрипт ниже. Он достаточно простой, чтобы его разбирать построчно, лишь скажу, что в отличие от многих примеров в инете по использованию IPreviewHandler, я не считываю файл в память, а либо открываю его напрямую просмотрщиком через интерфейс IInitializeWithFile (если он есть), либо создаю поток стандартной WinAPI функцией SHCreateStreamOnFileEx (она, оказывается, тоже поддерживается PyWin32) и передаю этот поток интерфейсу IInitializeWithStream — какой-то из двух интерфейсов у каждого просмотрщика есть обязательно.

filepreview.py
#!/usr/bin/python3
# -*- coding: utf-8 -*-

import pythoncom, win32comext
import win32comext.propsys.propsys as propsys
import win32comext.shell.shell as shellext
import pywintypes
import _preview

from PyQt5.QtCore import *
from PyQt5.QtWidgets import *

CLSID_IPreviewHandler = '{8895B1C6-B41F-4C1C-A562-0D564250836F}'
iid = pywintypes.IID(CLSID_IPreviewHandler)


class PreviewWin(QWidget):
    def __init__(self, parent=None):
        super().__init__(parent)
        self.handler = None
        self.isFirst = True
        self.topLay = QHBoxLayout(self)
        self.splitter = QSplitter(self)
        self.topLay.addWidget(self.splitter)
        self.model = QFileSystemModel(self)
        self.model.setRootPath(QDir.currentPath())
        self.tree = QTreeView(self.splitter)
        self.tree.setModel(self.model)
        cur = self.model.index(QDir.currentPath())
        self.tree.setCurrentIndex(cur)
        self.tree.expand(cur)
        self.view = QWidget()
        self.splitter.addWidget(self.tree)
        self.splitter.addWidget(self.view)
        self.tree.clicked.connect(self.previewIndex)
        self.tree.setColumnWidth(0, 200)
        self.setWindowState(Qt.WindowMaximized)


    def resizeEvent(self, event):
        super().resizeEvent(event)
        if self.handler:
            self.handler.SetRect(self.view.rect().getRect());

    def previewIndex(self, index):
        try:
            if self.handler:
                self.handler.Unload()
                self.handler = None
            if not index.isValid():
                return
            filePath = QDir.toNativeSeparators(self.model.filePath(index))
            ext = self.model.fileInfo(index).suffix()
            regPath = "HKEY_CLASSES_ROOT\\." + ext + "\\shellex\\" + CLSID_IPreviewHandler
            sets = QSettings(regPath, QSettings.NativeFormat)
            if not sets.contains("."):
                return
            classId = sets.value(".")
            if not classId:
                return
            self.handler = pythoncom.CoCreateInstance(classId, None, pythoncom.CLSCTX_LOCAL_SERVER, iid)
            if not self.handler:
                return
            STGM_READ = 0
            try:
                iwfile = self.handler.QueryInterface(propsys.IID_IInitializeWithFile)
            except:
                iwfile = None
            if iwfile:
                try:
                    iwfile.Initialize(filePath, STGM_READ)
                except:
                    iwfile = None
            if not iwfile:
                try:
                    iwstream = self.handler.QueryInterface(propsys.IID_IInitializeWithStream)
                except:
                    print(str(sys.exc_info()[1]))
                    iwstream = None
                if iwstream:
                    iis = shellext.SHCreateStreamOnFileEx(filePath,STGM_READ,0,False)
                    if iis:
                        iwstream.Initialize(iis, STGM_READ)
                    else:
                        return
                else:
                    print("Can't initialize preview for",filePath)
                    return
            r = self.view.rect().getRect()
            self.handler.SetWindow(self.view.winId(), r);
            self.handler.DoPreview();
            self.handler.SetFocus();
        except:
            print(str(sys.exc_info()[1]))        

if __name__ == '__main__':
    import sys
    app = QApplication(sys.argv)
    w = PreviewWin()
    w.show()
    sys.exit(app.exec_())


Все файлы сложены на Github.
Tags:
Hubs:
Total votes 17: ↑17 and ↓0+17
Comments6

Articles