Added remove duplicate lines from Ellen Wasbo
This commit is contained in:
parent
de524d8a06
commit
5562ca4275
@ -0,0 +1,35 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<inkscape-extension xmlns="http://www.inkscape.org/namespace/inkscape/extension">
|
||||||
|
<name>Remove Duplicate Lines</name>
|
||||||
|
<id>fablabchemnitz.de.remove_duplicate_lines</id>
|
||||||
|
<param type="notebook" name="tab">
|
||||||
|
<page name="options" gui-text="Options">
|
||||||
|
<label>Remove duplicate line segments from selected paths.</label>
|
||||||
|
<param name="selfPath" type="bool" gui-text="Include checking each path against itself.">false</param>
|
||||||
|
<label>Warning: segments smaller than tolerance set below might disappear</label>
|
||||||
|
<param name="minUse" type="bool" gui-text="Also remove line segments where nodes and controlpoints differ by less than">false</param>
|
||||||
|
<param name="tolerance" indent="3" type="float" precision="2" min="0" max="9999" gui-text="Tolerance">0.01</param>
|
||||||
|
<label>Unit as defined in document (File->Document Properties).</label>
|
||||||
|
<param name="interp" type="bool" gui-text="Let the remaining line segment be an interpolation of the matching line segments.">false</param>
|
||||||
|
</page>
|
||||||
|
<page name="help" gui-text="Information">
|
||||||
|
<label xml:space="preserve">
|
||||||
|
Remove duplicate line segments (with exact same coordinates) will always be performed.
|
||||||
|
|
||||||
|
For more information:
|
||||||
|
https://gitlab.com/EllenWasbo/inkscape-extension-removeduplicatelines
|
||||||
|
</label>
|
||||||
|
</page>
|
||||||
|
</param>
|
||||||
|
<effect>
|
||||||
|
<object-type>all</object-type>
|
||||||
|
<effects-menu>
|
||||||
|
<submenu name="FabLab Chemnitz">
|
||||||
|
<submenu name="Nesting/Cut Optimization"/>
|
||||||
|
</submenu>
|
||||||
|
</effects-menu>
|
||||||
|
</effect>
|
||||||
|
<script>
|
||||||
|
<command location="inx" interpreter="python">remove_duplicate_lines.py</command>
|
||||||
|
</script>
|
||||||
|
</inkscape-extension>
|
@ -0,0 +1,361 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
#
|
||||||
|
# Copyright (C) 2021 Ellen Wasboe, ellen@wasbo.net
|
||||||
|
#
|
||||||
|
# This program is free software; you can redistribute it and/or modify
|
||||||
|
# it under the terms of the GNU General Public License as published by
|
||||||
|
# the Free Software Foundation; either version 2 of the License, or
|
||||||
|
# (at your option) any later version.
|
||||||
|
|
||||||
|
# This program is distributed in the hope that it will be useful,
|
||||||
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
# GNU General Public License for more details.
|
||||||
|
#
|
||||||
|
# You should have received a copy of the GNU General Public License
|
||||||
|
# along with this program; if not, write to the Free Software
|
||||||
|
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
|
||||||
|
"""s
|
||||||
|
Remove duplicate lines by comparing cubic bezier control points after converting to cubic super path.
|
||||||
|
Optionally include searching for overlaps within the same path (which might cause trouble if the tolerance is too high and small neighbour segments are regarded as a match.
|
||||||
|
Optionally add a tolerance for the comparison.
|
||||||
|
Optionally interpolate the four control points of the remaining and the removed segment.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import inkex
|
||||||
|
from inkex import bezier, PathElement, CubicSuperPath, Transform
|
||||||
|
import numpy as np
|
||||||
|
from tkinter import messagebox
|
||||||
|
|
||||||
|
class removeDuplicateLines(inkex.EffectExtension):
|
||||||
|
|
||||||
|
def add_arguments(self, pars):
|
||||||
|
pars.add_argument("--tab", default="options")
|
||||||
|
pars.add_argument("--tolerance", default="0")
|
||||||
|
pars.add_argument("--minUse", type=inkex.Boolean, default=False)
|
||||||
|
pars.add_argument("--selfPath", type=inkex.Boolean, default=False)
|
||||||
|
pars.add_argument("--interp", type=inkex.Boolean, default=False)
|
||||||
|
|
||||||
|
"""Remove duplicate lines"""
|
||||||
|
def effect(self):
|
||||||
|
tolerance=float(self.options.tolerance)
|
||||||
|
if self.options.minUse == False:
|
||||||
|
tolerance=0
|
||||||
|
|
||||||
|
coords=[]#one segmentx8 subarray for each path and subpath (paths and subpaths treated equally)
|
||||||
|
pathNo=[]
|
||||||
|
subPathNo=[]
|
||||||
|
cPathNo=[]#counting alle paths and subpaths equally
|
||||||
|
removeSegmentPath=[]
|
||||||
|
removeSegmentSubPath=[]
|
||||||
|
removeSegment_cPath=[]
|
||||||
|
removeSegment=[]
|
||||||
|
matchSegmentPath=[]
|
||||||
|
matchSegmentSubPath=[]
|
||||||
|
matchSegment_cPath=[]
|
||||||
|
matchSegment=[]
|
||||||
|
matchSegmentRev=[]
|
||||||
|
|
||||||
|
if not self.svg.selected:
|
||||||
|
raise inkex.AbortExtension("Please select an object.")
|
||||||
|
nFailed=0
|
||||||
|
nInkEffect=0
|
||||||
|
p=0
|
||||||
|
c=0
|
||||||
|
idsNotPath=[]
|
||||||
|
for id, elem in self.svg.selection.id_dict().items():
|
||||||
|
thisIsPath=True
|
||||||
|
if elem.get('d')==None:
|
||||||
|
thisIsPath=False
|
||||||
|
nFailed+=1
|
||||||
|
idsNotPath.append(id)
|
||||||
|
if elem.get('inkscape:path-effect') != None:
|
||||||
|
thisIsPath=False
|
||||||
|
nInkEffect+=1
|
||||||
|
idsNotPath.append(id)
|
||||||
|
|
||||||
|
if thisIsPath:
|
||||||
|
#apply transformation matrix if present
|
||||||
|
csp = CubicSuperPath(elem.get('d'))
|
||||||
|
elem.path=elem.path.to_absolute()
|
||||||
|
transformMat = Transform(elem.get('transform'))
|
||||||
|
cpsTransf=csp.transform(transformMat)
|
||||||
|
elem.path = cpsTransf.to_path(curves_only=True)
|
||||||
|
pp=elem.path
|
||||||
|
|
||||||
|
s=0
|
||||||
|
#create matrix with segment coordinates p1x p1y c1x c1y c2x c2y p2x p2y
|
||||||
|
for sub in pp.to_superpath():
|
||||||
|
coordsThis=np.zeros((len(sub)-1,8))
|
||||||
|
|
||||||
|
i=0
|
||||||
|
while i <= len(sub) - 2:
|
||||||
|
coordsThis[i][0]=sub[i][1][0]
|
||||||
|
coordsThis[i][1]=sub[i][1][1]
|
||||||
|
coordsThis[i][2]=sub[i][2][0]
|
||||||
|
coordsThis[i][3]=sub[i][2][1]
|
||||||
|
coordsThis[i][4]=sub[i+1][0][0]
|
||||||
|
coordsThis[i][5]=sub[i+1][0][1]
|
||||||
|
coordsThis[i][6]=sub[i+1][1][0]
|
||||||
|
coordsThis[i][7]=sub[i+1][1][1]
|
||||||
|
|
||||||
|
i+=1
|
||||||
|
|
||||||
|
coords.append(coordsThis)
|
||||||
|
pathNo.append(p)
|
||||||
|
subPathNo.append(s)
|
||||||
|
cPathNo.append(c)
|
||||||
|
c+=1
|
||||||
|
s+=1
|
||||||
|
p+=1
|
||||||
|
if nFailed > 0:
|
||||||
|
messagebox.showwarning('Warning',str(nFailed)+' selected elements did not have a path. Groups, shapeelements and text will be ignored.')
|
||||||
|
|
||||||
|
if nInkEffect > 0:
|
||||||
|
messagebox.showwarning('Warning',str(nInkEffect)+' selected elements have an inkscape:path-effect applied. These elements will be ignored to avoid confusing results. Apply Paths->Object to path (Shift+Ctrl+C) and retry .')
|
||||||
|
|
||||||
|
origCoords=[]
|
||||||
|
for item in coords: origCoords.append(np.copy(item))#make a real copy (not a reference that changes with the original
|
||||||
|
#search for overlapping or close segments
|
||||||
|
#for each segment find if difference of any x or y is less than tolerance - if so - calculate 2d-distance and find if all 4 less than tolerance
|
||||||
|
#repeat with reversed segment
|
||||||
|
#if match found set match coordinates to -1000 to mark this to be removed and being ignored later on
|
||||||
|
i=0
|
||||||
|
while i <= len(coords)-1:#each path or subpath
|
||||||
|
j=0
|
||||||
|
while j<=len(coords[i][:,0])-1:#each segment j of path i
|
||||||
|
k=0
|
||||||
|
while k<=len(coords)-1:#search all other subpaths
|
||||||
|
evalPath=True
|
||||||
|
if k == i and self.options.selfPath == False:#do not test path against itself
|
||||||
|
evalPath=False
|
||||||
|
if evalPath:
|
||||||
|
segmentCoords=np.array(coords[i][j,:])
|
||||||
|
if segmentCoords[0] != -1000 and segmentCoords[1] != -1000:
|
||||||
|
searchCoords=np.array(coords[k])
|
||||||
|
if k==i:
|
||||||
|
searchCoords[j,:]=-2000#avoid comparing segment with itself
|
||||||
|
subtr=np.abs(searchCoords-segmentCoords)
|
||||||
|
maxval=subtr.max(1)
|
||||||
|
lessTol=np.argwhere(maxval<tolerance)
|
||||||
|
matchThis=False
|
||||||
|
matchThisRev=False
|
||||||
|
finalK=0
|
||||||
|
lesstolc=0
|
||||||
|
if len(lessTol) > 0:#proceed to calculate 2d distance where both x and y distance is less than tolerance
|
||||||
|
c=0
|
||||||
|
while c < len(lessTol):
|
||||||
|
dists=np.zeros(4)
|
||||||
|
dists[0]=np.sqrt(np.add(np.power(subtr[lessTol[c,0]][0],2),np.power(subtr[lessTol[c,0]][1],2)))
|
||||||
|
dists[1]=np.sqrt(np.add(np.power(subtr[lessTol[c,0]][2],2),np.power(subtr[lessTol[c,0]][3],2)))
|
||||||
|
dists[2]=np.sqrt(np.add(np.power(subtr[lessTol[c,0]][4],2),np.power(subtr[lessTol[c,0]][5],2)))
|
||||||
|
dists[3]=np.sqrt(np.add(np.power(subtr[lessTol[c,0]][6],2),np.power(subtr[lessTol[c,0]][7],2)))
|
||||||
|
if dists.max() < tolerance:
|
||||||
|
matchThis=True
|
||||||
|
finalK=k
|
||||||
|
lesstolc=lessTol[c]
|
||||||
|
c+=1
|
||||||
|
if matchThis == False:#try reversed
|
||||||
|
segmentCoordsRev=[segmentCoords[6], segmentCoords[7],segmentCoords[4],segmentCoords[5],segmentCoords[2],segmentCoords[3],segmentCoords[0],segmentCoords[1]]
|
||||||
|
subtr=np.abs(searchCoords-segmentCoordsRev)
|
||||||
|
maxval=subtr.max(1)
|
||||||
|
lessTol=np.argwhere(maxval<tolerance)
|
||||||
|
if len(lessTol) > 0:#proceed to calculate 2d distance where both x and y distance is less than tolerance
|
||||||
|
c=0
|
||||||
|
while c < len(lessTol):
|
||||||
|
dists=np.zeros(4)
|
||||||
|
dists[0]=np.sqrt(np.add(np.power(subtr[lessTol[c,0]][0],2),np.power(subtr[lessTol[c,0]][1],2)))
|
||||||
|
dists[1]=np.sqrt(np.add(np.power(subtr[lessTol[c,0]][2],2),np.power(subtr[lessTol[c,0]][3],2)))
|
||||||
|
dists[2]=np.sqrt(np.add(np.power(subtr[lessTol[c,0]][4],2),np.power(subtr[lessTol[c,0]][5],2)))
|
||||||
|
dists[3]=np.sqrt(np.add(np.power(subtr[lessTol[c,0]][6],2),np.power(subtr[lessTol[c,0]][7],2)))
|
||||||
|
if dists.max() < tolerance:
|
||||||
|
matchThis=True
|
||||||
|
matchThisRev=True
|
||||||
|
finalK=k
|
||||||
|
lesstolc=lessTol[c]
|
||||||
|
c+=1
|
||||||
|
|
||||||
|
if matchThis:
|
||||||
|
coords[finalK][lesstolc,:]=-1000
|
||||||
|
removeSegmentPath.append(pathNo[finalK])
|
||||||
|
removeSegmentSubPath.append(subPathNo[finalK])
|
||||||
|
removeSegment_cPath.append(cPathNo[finalK])
|
||||||
|
removeSegment.append(lesstolc)
|
||||||
|
matchSegmentPath.append(pathNo[i])
|
||||||
|
matchSegmentSubPath.append(subPathNo[i])
|
||||||
|
matchSegment_cPath.append(cPathNo[i])
|
||||||
|
matchSegment.append(j)
|
||||||
|
matchSegmentRev.append(matchThisRev)
|
||||||
|
|
||||||
|
k+=1
|
||||||
|
j+=1
|
||||||
|
i+=1
|
||||||
|
|
||||||
|
#(interpolate remaining and) remove segments with a match
|
||||||
|
if len(removeSegmentPath) > 0:
|
||||||
|
removeSegmentPath=np.array(removeSegmentPath)
|
||||||
|
removeSegmentSubPath=np.array(removeSegmentSubPath)
|
||||||
|
removeSegment_cPath=np.array(removeSegment_cPath)
|
||||||
|
removeSegment=np.array(removeSegment)
|
||||||
|
matchSegmentPath=np.array(matchSegmentPath)
|
||||||
|
matchSegment_cPath=np.array(matchSegment_cPath)
|
||||||
|
matchSegmentSubPath=np.array(matchSegmentSubPath)
|
||||||
|
matchSegment=np.array(matchSegment)
|
||||||
|
matchSegmentRev=np.array(matchSegmentRev)
|
||||||
|
|
||||||
|
#first interpolate remaining segment
|
||||||
|
if self.options.interp:
|
||||||
|
idx=np.argsort(matchSegmentPath)
|
||||||
|
matchSegmentPath=matchSegmentPath[idx]
|
||||||
|
matchSegment_cPath=matchSegment_cPath[idx]
|
||||||
|
matchSegmentSubPath=matchSegmentSubPath[idx]
|
||||||
|
matchSegment=matchSegment[idx]
|
||||||
|
matchSegmentRev=matchSegmentRev[idx]
|
||||||
|
remSegmentPath=removeSegmentPath[idx]
|
||||||
|
remSegment_cPath=removeSegment_cPath[idx]
|
||||||
|
remSegment=removeSegment[idx]
|
||||||
|
|
||||||
|
i=0
|
||||||
|
for id, elem in self.svg.selection.id_dict().items():#each path
|
||||||
|
if not id in idsNotPath:
|
||||||
|
if i in matchSegmentPath:
|
||||||
|
idxi=np.argwhere(matchSegmentPath==i)
|
||||||
|
idxi=idxi.reshape(-1)
|
||||||
|
icMatch=matchSegment_cPath[idxi]
|
||||||
|
iSegMatch=matchSegment[idxi]
|
||||||
|
iSegMatchRev=matchSegmentRev[idxi]
|
||||||
|
iSubMatch=matchSegmentSubPath[idxi]
|
||||||
|
iSegRem=remSegment[idxi]
|
||||||
|
icRem=remSegment_cPath[idxi]
|
||||||
|
iPathRem=remSegmentPath[idxi]
|
||||||
|
new=[]
|
||||||
|
j=0
|
||||||
|
for sub in elem.path.to_superpath():#each subpath
|
||||||
|
idxj=np.argwhere(iSubMatch==j)
|
||||||
|
idxj=idxj.reshape(-1)
|
||||||
|
this_cMatch=icMatch[idxj]
|
||||||
|
thisSegMatch=iSegMatch[idxj]
|
||||||
|
thisSegMatchRev=iSegMatchRev[idxj]
|
||||||
|
thisSegRem=iSegRem[idxj].reshape(-1)
|
||||||
|
this_cRem=icRem[idxj]
|
||||||
|
thisPathRem=iPathRem[idxj]
|
||||||
|
k=0
|
||||||
|
while k<len(thisSegMatch):
|
||||||
|
|
||||||
|
if thisSegMatchRev[k]==False:
|
||||||
|
x1interp=0.5*(sub[thisSegMatch[k]][1][0]+origCoords[this_cRem[k]][thisSegRem[k],0])
|
||||||
|
y1interp=0.5*(sub[thisSegMatch[k]][1][1]+origCoords[this_cRem[k]][thisSegRem[k],1])
|
||||||
|
cx1interp=0.5*(sub[thisSegMatch[k]][2][0]+origCoords[this_cRem[k]][thisSegRem[k],2])
|
||||||
|
cy1interp=0.5*(sub[thisSegMatch[k]][2][1]+origCoords[this_cRem[k]][thisSegRem[k],3])
|
||||||
|
x2interp=0.5*(sub[thisSegMatch[k]+1][1][0]+origCoords[this_cRem[k]][thisSegRem[k],6])
|
||||||
|
y2interp=0.5*(sub[thisSegMatch[k]+1][1][1]+origCoords[this_cRem[k]][thisSegRem[k],7])
|
||||||
|
cx2interp=0.5*(sub[thisSegMatch[k]+1][0][0]+origCoords[this_cRem[k]][thisSegRem[k],4])
|
||||||
|
cy2interp=0.5*(sub[thisSegMatch[k]+1][0][1]+origCoords[this_cRem[k]][thisSegRem[k],5])
|
||||||
|
else:
|
||||||
|
x1interp=0.5*(sub[thisSegMatch[k]][1][0]+origCoords[this_cRem[k]][thisSegRem[k],6])
|
||||||
|
y1interp=0.5*(sub[thisSegMatch[k]][1][1]+origCoords[this_cRem[k]][thisSegRem[k],7])
|
||||||
|
cx1interp=0.5*(sub[thisSegMatch[k]][2][0]+origCoords[this_cRem[k]][thisSegRem[k],4])
|
||||||
|
cy1interp=0.5*(sub[thisSegMatch[k]][2][1]+origCoords[this_cRem[k]][thisSegRem[k],5])
|
||||||
|
x2interp=0.5*(sub[thisSegMatch[k]+1][1][0]+origCoords[this_cRem[k]][thisSegRem[k],0])
|
||||||
|
y2interp=0.5*(sub[thisSegMatch[k]+1][1][1]+origCoords[this_cRem[k]][thisSegRem[k],1])
|
||||||
|
cx2interp=0.5*(sub[thisSegMatch[k]+1][0][0]+origCoords[this_cRem[k]][thisSegRem[k],2])
|
||||||
|
cy2interp=0.5*(sub[thisSegMatch[k]+1][0][1]+origCoords[this_cRem[k]][thisSegRem[k],3])
|
||||||
|
|
||||||
|
sub[thisSegMatch[k]][1]=[x1interp,y1interp]
|
||||||
|
sub[thisSegMatch[k]][2]=[cx1interp,cy1interp]
|
||||||
|
sub[thisSegMatch[k]+1][1]=[x2interp,y2interp]
|
||||||
|
sub[thisSegMatch[k]+1][0]=[cx2interp,cy2interp]
|
||||||
|
|
||||||
|
if thisSegMatch[k]==0:
|
||||||
|
sub[thisSegMatch[k]][0]=[x1interp,y1interp]
|
||||||
|
if thisSegMatch[k]+1==len(sub)-1:
|
||||||
|
sub[thisSegMatch[k]+1][2]=[x2interp,y2interp]
|
||||||
|
k+=1
|
||||||
|
|
||||||
|
new.append(sub)
|
||||||
|
j+=1
|
||||||
|
|
||||||
|
elem.path = CubicSuperPath(new).to_path(curves_only=True)
|
||||||
|
|
||||||
|
i+=1
|
||||||
|
|
||||||
|
#remove
|
||||||
|
i=0
|
||||||
|
for id, elem in self.svg.selection.id_dict().items():#each path
|
||||||
|
if not id in idsNotPath:
|
||||||
|
idx=np.argwhere(removeSegmentPath==i)
|
||||||
|
if len(idx) > 0:
|
||||||
|
idx=idx.reshape(1,-1)
|
||||||
|
idx=idx[0]
|
||||||
|
new=[]
|
||||||
|
j=0
|
||||||
|
for sub in elem.path.to_superpath():#each subpath
|
||||||
|
thisSegRem=removeSegment[idx]
|
||||||
|
keepLast=False if len(sub)-2 in thisSegRem else True
|
||||||
|
keepNext2Last=False if len(sub)-3 in thisSegRem else True
|
||||||
|
thisSubPath=removeSegmentSubPath[idx]
|
||||||
|
idx2=np.argwhere(removeSegmentSubPath[idx]==j)
|
||||||
|
if len(idx2) > 0:
|
||||||
|
idx2=idx2.reshape(1,-1)
|
||||||
|
idx2=idx2[0]
|
||||||
|
thisSegRem=thisSegRem[idx2]
|
||||||
|
if len(thisSegRem) < len(sub)-1:#if any segment to be kept
|
||||||
|
#find first segment
|
||||||
|
k=0
|
||||||
|
if 0 in thisSegRem:#remove first segment
|
||||||
|
proceed=True
|
||||||
|
while proceed:
|
||||||
|
if k+1 in thisSegRem:
|
||||||
|
k+=1
|
||||||
|
else:
|
||||||
|
proceed=False
|
||||||
|
k+=1
|
||||||
|
new.append([sub[k]])
|
||||||
|
if sub[k+1]!=new[-1][-1]:#avoid duplicated nodes
|
||||||
|
new[-1].append(sub[k+1])
|
||||||
|
new[-1][-1][0]=new[-1][-1][1]
|
||||||
|
else:
|
||||||
|
new.append([sub[0]])
|
||||||
|
if sub[1]!=new[-1][-1]:#avoid duplicated nodes
|
||||||
|
new[-1].append(sub[1])
|
||||||
|
k+=1
|
||||||
|
|
||||||
|
#rest of segments
|
||||||
|
while k<len(sub)-1:
|
||||||
|
if k in thisSegRem:
|
||||||
|
new[-1][-1][-1]=new[-1][-1][1]#stop subpath
|
||||||
|
cut=True
|
||||||
|
while cut:
|
||||||
|
if k+1 in thisSegRem:
|
||||||
|
k+=1
|
||||||
|
else:
|
||||||
|
cut=False
|
||||||
|
k+=1
|
||||||
|
if k<len(sub)-1:
|
||||||
|
#start new subpath, start by checking that last sub did contain more than one element
|
||||||
|
if len(new[-1])==1: new.pop()
|
||||||
|
new.append([sub[k]])#start new subpath
|
||||||
|
new[-1][-1][0]=new[-1][-1][1]
|
||||||
|
if sub[k+1]!=new[-1][-1]:#avoid duplicated nodes
|
||||||
|
new[-1].append(sub[k+1])
|
||||||
|
k+=1
|
||||||
|
else:
|
||||||
|
if sub[k+1]!=new[-1][-1]:#avoid duplicated nodes
|
||||||
|
new[-1].append(sub[k+1])
|
||||||
|
k+=1
|
||||||
|
if keepLast:
|
||||||
|
if sub[-1]!=new[-1][-1]:#avoid duplicated nodes
|
||||||
|
new[-1].append(sub[-1])
|
||||||
|
|
||||||
|
if len(new) > 0:
|
||||||
|
if len(new[-1])==1: new.pop()
|
||||||
|
else:
|
||||||
|
new.append(sub)#add as is
|
||||||
|
|
||||||
|
j+=1
|
||||||
|
|
||||||
|
elem.path = CubicSuperPath(new).to_path(curves_only=True)
|
||||||
|
i+=1
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
removeDuplicateLines().run()
|
Reference in New Issue
Block a user